Git amend 정리 + Spring Security 7 & JWT 초기 세팅(실무 패턴) 정리

오병택·2026년 1월 30일
post-thumbnail

0. 왜 이 글을 쓰게 됐나

예전 프로젝트에서 Security+JWT를 적용해 둔 코드가 있었는데, 새 프로젝트에서도 “그대로 가져가도 되는지”, 그리고 실제로 실무에서 어떤 방식이 많이 쓰이는지(로그인 vs 일반 요청)까지 한 번에 정리하고 싶었다.
추가로, IntelliJ에서 amend가 뭔지 헷갈렸던 부분과, Spring Security 7에서 matcher 방식이 바뀐 것도 같이 정리한다.

1) git commit --amend: 커밋을 “수정”이 아니라 “교체”하는 기능

1-1. amend가 정확히 하는 일

git commit --amend는 가장 최근 커밋 1개를 덮어써서 새 커밋으로 다시 만드는 것이다.

즉,

  • 최근 커밋 메시지를 고치거나

  • 최근 커밋에 파일 변경사항을 더 포함시키거나

  • 둘 다 동시에 할 때 쓴다.

커밋이 하나 더 생기는 게 아니라, 직전 커밋이 교체되고 커밋 해시가 바뀐다.

1-2. IntelliJ에서 amend 모드가 “직전 커밋”을 보여주는 이유

IDE에서 amend를 켜면 “새 커밋 생성”이 아니라 “직전 커밋 수정 모드”라서, 직전 커밋 메시지가 화면에 뜬다.

여기서 할 수 있는 건 2가지:

(1) 커밋 메시지 수정

메시지 수정 후 커밋 → 직전 커밋 메시지가 바뀜(커밋 해시도 변경)

(2) 직전 커밋에 변경사항 추가

amend 전에 파일을 수정하고(staged 여부는 도구/설정에 따라 다름) amend로 커밋하면
→ 그 변경사항이 직전 커밋에 “합쳐져서” 들어감

중요한 포인트: amend 화면에서 직전 커밋 내용을 직접 편집하는 게 아니라,
현재 워킹트리/스테이지에 있는 변경사항을 직전 커밋에 합치는 방식이다.

1-3. 언제 조심해야 하나?

  • 이미 push된 커밋을 amend하면 원격 히스토리와 달라짐 → 보통 force push가 필요

  • 팀 작업이면 위험(히스토리 꼬임)

반대로,

  • 아직 push 안 했고 로컬에서만 커밋한 상태라면 amend는 거의 안전하게 써도 된다.

2) Security+JWT 실무 패턴: “로그인”과 “그 외 요청”은 흐름이 다르다

실무에서 제일 흔한 혼용 패턴은 아래 구조다.

2-1. 로그인(아이디/비밀번호) 요청: AuthenticationManager → Provider

로그인은 “사용자가 진짜인지”를 검증해야 하니 보통 다음 흐름을 탄다.

  • 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를 직접 호출하지 않을 때

2-2. 로그인 이후 요청: JWT 검증만 하고 SecurityContextHolder에 넣는다

로그인 이후 요청은 이미 토큰을 가지고 오니,

  • 서버는 토큰 유효성만 확인

  • 유효하면 Authentication을 만들어

  • SecurityContextHolder에 세팅

즉,

  • 로그인 = authenticate(인증 수행)

  • 이후 요청 = validate(검증) + context 세팅

이 “혼용”이 실무에서 흔한 이유는 각 단계가 맡는 역할이 다르기 때문이다.

3) JwtFilter + JwtUtil 분리: 2개로 나누는 게 맞나?

결론: 흔하다

3-1. JwtFilter 책임 (웹 계층)

  • 요청에서 Authorization 헤더 읽기

  • Bearer 토큰 존재 여부 판단

  • 토큰 검증/파싱 로직을 호출

  • 인증 객체 생성 → SecurityContextHolder에 설정

3-2. JwtUtil 책임 (JWT 도메인 로직)

  • Access/Refresh 토큰 생성

  • 서명키 로딩

  • Claims 파싱/검증, 만료 체크 등

프로젝트가 커지면 Provider(Service) 레이어를 추가해 getAuthentication(token) 같은 고수준 API로 확장하기도 한다.
하지만 필터+유틸 2분리만으로도 실무에서 충분히 자주 쓰인다.

이번엔 Provider 레이어를 추가하여 3 분리로 해볼 예정이다.

4) JWT 3분리 구조: Filter / Provider(Service) / Parser(Util)

프로젝트가 커지면 JwtUtil이 “토큰 생성 + 파싱 + 검증 + 인증 객체 생성까지” 다 떠안게 되고, 이 상태에서 refresh/OAuth2/권한 정책까지 붙으면 유지보수가 급격히 어려워진다.
그래서 실무에선 아래처럼 역할을 더 명확히 쪼개는 3분리가 자주 쓰인다.

9-1. 역할 분담

① JwtFilter (웹 계층)

  • 요청에서 토큰을 추출

  • 스킵/허용 경로면 건너뛰기

  • 토큰이 있으면 Provider에 위임

  • Provider가 만든 Authentication을 SecurityContextHolder에 세팅

➡️ HTTP/서블릿 책임만 가진다.

② JwtTokenProvider (or JwtService) (인증 조립 계층)

  • 토큰 검증 및 claims 파싱(파서에 위임)

  • claims에서 userId/email 등 식별자를 꺼냄

  • DB에서 UserDetails 로딩

  • Authentication 객체 생성해서 반환 (getAuthentication(token))

➡️ “토큰 → 인증 객체” 변환을 한 곳에 모은다.

③ JwtTokenParser (or JwtVerifier/Util) (JWT 로우레벨 계층)

  • 서명키 기반 파싱

  • claims 추출

  • 만료/서명 검증

  • (access/refresh 분리 파싱)

➡️ JWT 라이브러리(JJWT) API 의존을 여기로 몰아넣는다.

4-2. getAuthentication(token)이 하는 일

이 메서드는 “토큰 문자열”을 받아서 Spring Security의 Authentication을 만들어 반환하는 역할이다.

흐름은 보통:

  • token → claims 파싱

  • claims에서 sub(권장: userId) 추출

  • UserDetailsService로 사용자 로딩

  • UsernamePasswordAuthenticationToken 생성 후 반환

즉, 기존에 JwtFilter가 직접 하던 “DB 조회 + Authentication 생성”을 Provider로 옮겨서 필터를 얇게 만든다.

5) 기존 JwtUtil/JwtFilter 코드를 “그대로” 가져갈 때 주의할 점

구조는 재사용하기 좋지만, 내용은 프로젝트마다 조정 포인트가 있다.

5-1. 토큰을 “Bearer 포함해서” 생성하는 방식

토큰 문자열에 "Bearer "를 포함해서 만들면, 저장/전송 경로가 늘어날 때 헷갈릴 수 있다.
보통은 토큰은 raw 문자열로 생성하고, 헤더에서 "Bearer "를 붙이는 쪽이 관리가 쉽다.

5-2. substringToken에서 NullPointerException 던지기

토큰이 없을 때 NPE를 던지면 디버깅/예외 분기가 지저분해질 수 있다.
보통 IllegalArgumentException 또는 커스텀 예외를 쓴다.

5-3. claims에서 email로 사용자 조회 vs sub(userId)로 조회

email은 변경될 수 있다. 그래서 토큰에 email을 넣고 email로 조회하면
“이메일 변경 후 재로그인 안 한 사용자” 같은 추가 케이스가 생긴다.

실무에선 보통:

  • sub에 userId(불변 식별자) 를 넣고

  • userId로 조회하는 방식을 선호한다.

5-4. 필터에서 response.sendError로 끝내기 vs EntryPoint/DeniedHandler로 통일

필터에서 sendError()로 직접 끝내면

  • 프로젝트에서 커스텀 EntryPoint/DeniedHandler로 만들어 둔

  • “에러 응답 포맷 통일” 흐름을 우회할 수 있다.

팀 스타일에 따라

  • 필터는 예외를 던지고 EntryPoint로 JSON 통일

  • 또는 필터가 직접 JSON 응답 작성
    둘 중 하나로 “일관성”을 맞추는 게 중요하다.

6) JJWT 버전 업(0.11.5 → 0.13.x) 시 변경되는 부분

결론부터: 가장 큰 변화는 “파싱/검증 API”다.
토큰 생성(create)은 영향이 상대적으로 적고, extractClaims 같은 파싱 코드가 많이 바뀐다.

6-1. Gradle 의존성은 그대로(버전만 올리는 형태)

(기본 구성은 동일: 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"
}

(버전은 예시. 최신 라인업을 쓰면 됨)

6-2. 파싱 API 변경 요약(핵심만)

(1) parserBuilder() → parser()

기존:

Jwts.parserBuilder()...

변경:

Jwts.parser()...

(2) setSigningKey(...) → verifyWith(...)

기존:

Jwts.parserBuilder().setSigningKey(key)...

변경:

Jwts.parser().verifyWith(secretKey)...

여기서 key 타입이 javax.crypto.SecretKey(대칭키)인 게 일반적이다.

(3) parseClaimsJws(token).getBody() → parseSignedClaims(token).getPayload()

기존:

.parseClaimsJws(token).getBody()

변경:

.parseSignedClaims(token).getPayload()

6-3. 업그레이드하면서 실무에서 체감되는 변화(주의 포인트)

A) Claims/Header가 불변(immutable)로 바뀌는 흐름

파싱된 claims를 수정하려는 코드가 있으면 깨질 수 있다.
(보통은 파싱된 claims는 읽기만 하고 수정하지 않으니 영향이 없을 때가 많다.)

B) SignatureAlgorithm enum deprecated

SignatureAlgorithm.HS256 같은 enum을 계속 써도 당장 동작은 하는데, 경고가 뜰 수 있다.
점진적으로 최신 방식(라이브러리 권장 방식)으로 바꾸는 게 좋다.

C) “JWT 라이브러리 의존”을 Parser에 몰아두는 게 더 중요해짐

버전 업 시 바뀌는 게 대부분 parser/verify 쪽 API라서,
앞에서 말한 3분리 구조에서 Parser 한 곳만 고치면 전체 영향이 최소화된다.

6-4. 왜 3분리 구조가 버전 업에서 특히 유리한가?

JJWT 업그레이드로 바뀌는 코드는 거의 “토큰 파싱/검증” 부분이다.

그걸 JwtTokenParser로 몰아두면,

Filter / Provider는 거의 손댈 게 없다.

즉, 라이브러리 변경을 한 파일(또는 한 클래스)에 가둬버리는 효과가 있다.

7) shouldNotFilter 추천?

내 필터는 이미

  • Authorization 헤더가 없으면 그냥 통과
    라서 shouldNotFilter 없이도 동작은 잘 된다.

하지만, 여러 이슈가 발생할 수 있다.

shouldNotFilter()가 없을 때 생길 수 있는 실무 이슈들

1) “불필요한 작업”이 계속 발생

permitAll/API 문서/헬스체크 같은 요청도 매번 필터가 실행돼서

  • 헤더 읽고

  • 조건 검사하고

  • (추가 로직이 생기면 더)

이게 쌓이면 성능도 성능인데, 필터가 커질수록 낭비가 커져.

2) 나중에 필터 로직이 조금만 복잡해져도 사고 확률이 올라감

지금은 “헤더 없으면 바로 통과”라 안전해 보이지만,
필터가 커지면서 흔히 이런 게 추가돼:

  • 특정 헤더/쿠키도 검사

  • access token 없으면 refresh token 검사

  • 로그/메트릭 추가

  • 예외 처리 강화

이때 permitAll 경로에서 괜히 에러를 내거나 “쓸데없는 401”을 내는 실수가 나오기 쉬워.

3) OAuth2/로그인 플로우와 섞일 때 디버깅이 쉬워짐

특히 OAuth2는 리다이렉트/콜백이 있어서,
“여기는 JWT 필터가 아예 관여하지 않는다”를 코드로 명시해두면
문제 생겼을 때 원인 범위를 빨리 줄일 수 있음.

추천한 이유는 동작이 아니라 유지보수/안정성 때문이다.

  • permitAll 경로에서 불필요한 로직 실행을 줄이고

  • 나중에 refresh/OAuth2 같은 예외 플로우가 늘 때
    JWT 필터가 괜히 개입해서 흐름을 깨는 실수를 줄이기 위해서다.

특히

  • refresh 엔드포인트는 access token이 없거나 만료여도 정상 동작해야 하고

  • OAuth2 콜백/리다이렉트 경로는 JWT 필터가 끼면 디버깅이 힘들어진다

그래서 “스킵 규칙”을 명확히 하는 게 커질수록 이득이다.

8) Spring Security 7: HandlerMappingIntrospector가 아니라 PathPatternRequestMatcher

Security 7에서는 matcher 전략이 바뀌었다.
그래서 HandlerMappingIntrospector / MvcRequestMatcher가 아니라, PathPatternRequestMatcher를 쓰는 게 자연스럽다.

8-1. 공용 matcher Bean을 만들어 permitAll과 필터 스킵을 “같이” 쓰기

@Bean
public RequestMatcher publicEndpoints() {
    var p = PathPatternRequestMatcher.withDefaults().basePath("/api");

    return new OrRequestMatcher(
        p.matcher("/auth/**")
        // 필요하면 여기에 추가
    );
}

8-2. JwtFilter에 주입해서 shouldNotFilter에서 사용

@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);
    }
}

9) “왜 저게 자동으로 사용되는지” (@Bean 동작 원리)

여기서 헷갈리기 쉬운 포인트는 이거다.

9-1. @Bean 메서드는 내가 호출하는 게 아니다

@Bean은 “객체를 생성해서 스프링 컨테이너에 등록”하는 선언이다.
애플리케이션 시작 시점에 스프링이 필요한 순서대로 메서드를 호출해 빈을 만들고 관리한다.

9-2. @Bean 메서드 파라미터는 스프링이 자동 주입한다

@Bean
public Filter jwtFilter(RequestMatcher publicEndpoints) {
    return new JwtFilter(publicEndpoints);
}

여기서 RequestMatcher publicEndpoints

  • 컨테이너에 등록된 RequestMatcher 빈을 스프링이 찾아서

  • 자동으로 파라미터에 넣어주는 것이다.

즉 “내가 어딘가에서 실행해야”가 아니라,

  • 스프링이 빈 생성 과정에서 자동으로 연결한다.
profile
걱정하지 말고 일단 해봐!

0개의 댓글