React에서 Spring으로

JWT로 인증하기

stateless 구현과 신선도 함정의 클라이맥스

10장 · TASKBOARD에 출입증 검문소 끼우기

목차 · 진행 0%

    당신이 React 앱에 로그인 화면을 붙였다고 해 보자. 사용자가 아이디와 비밀번호를 넣고 “로그인” 버튼을 누른다. 서버가 “맞네, 환영해”라고 답한다. 그런데 여기서부터가 진짜 문제다. 그다음 사용자가 할 일을 만들려고 POST /api/tasks를 때릴 때, 서버는 이 요청을 보낸 사람이 방금 로그인한 그 사람이라는 걸 어떻게 알아볼까?

    HTTP는 본래 한 번 요청하고 답하면 서로를 잊어버리는, 기억력이 없는(stateless) 프로토콜이다. 매 요청은 처음 보는 손님처럼 도착한다. 그러니 “방금 로그인했잖아요”는 서버에게 통하지 않는다. 요청 하나하나가 자기가 누구인지를 스스로 증명해야 한다. 그렇다면 어떻게 해야 할까? 로그인할 때 서버가 일종의 ‘출입증’을 하나 발급하고, 사용자는 그 뒤로 모든 요청에 그 출입증을 붙여 보내면 된다. 서버는 출입증만 확인하면 “아, 김아무개구나” 하고 알아본다.

    그 출입증을 만드는 가장 흔한 방법 중 하나가 이번 장의 주인공, JWT(JSON Web Token)다. 9장에서는 필터체인이라는 검문소의 줄을 그렸다. 오늘은 그 줄에 진짜 인증 필터를 하나 끼워, 출입증을 검사하는 실제 검문소를 세운다. 그리고 그 과정에서 이 책 전체를 관통해 온 신선도 함정이 마침내 정면으로 터진다. 마음을 단단히 먹고 들어가 보자.

    출입증 안에 뭐가 들었나 — JWT의 세 조각

    JWT는 거창한 이름과 달리 구조가 단순하다. RFC 7519로 규격이 정해진 토큰인데, 점(.) 두 개로 나뉜 세 조각의 문자열일 뿐이다.

    xxxxx.yyyyy.zzzzz
    헤더   페이로드  서명

    각 조각이 무슨 일을 하는지 하나씩 보자.

    • 헤더(header): “이 토큰은 어떤 알고리즘으로 서명됐다”는 메타 정보를 담는다. 예컨대 “HS256으로 서명함” 같은 내용이다.
    • 페이로드(payload): 진짜 알맹이다. “이 사용자는 누구인가(sub)”, “언제 만료되나(exp)” 같은 정보(클레임claim이라 부른다)가 들어간다. 우리 TaskBoard라면 “사용자 ID는 42, 역할은 USER” 같은 게 여기 실린다.
    • 서명(signature): 헤더와 페이로드를 서버만 아는 비밀 키로 봉인한 도장이다.
    예측 · 앱 보충먼저 답해 보세요

    토큰을 중간에 가로챈 사람이 서버의 비밀 키 없이 페이로드의 “사용자 ID 42”를 읽을 수 있을까?

    실험 · 앱 보충JWT 디코더: 누구나 읽을 수 있고, 서버만 믿을 수 있다

    토큰을 고치면 바로 디코딩됩니다. 디코딩은 Base64URL을 푸는 것일 뿐 키가 필요 없습니다. 서명 판정만은 비밀 키를 가진 서버(여기선 흉내)의 몫입니다.

    토큰 손대기
    서버 시계: 발급 5분 뒤
    서버로

    헤더누구나 읽음

    alg: "HS256"

    페이로드누구나 읽음

    sub: "ara"
    iat: 1790899200 (2026-10-02 09:00 KST)
    exp: 1790901000 (2026-10-02 09:30 KST)
    서버 시각 2026-10-02 09:05 KST 기준 아직 유효

    서명서버만 알 수 있음

    HMAC-SHA256(헤더.페이로드, 비밀 키). 키가 없는 브라우저는 이 값이 맞는지 판단할 수 없습니다.
    서버 로그
    -- 로그인 응답으로 받은 토큰입니다. 위에서 바로 디코딩되는 것을 보고, 서버에 보내 보세요
    • 해볼 것
    • 유효한 토큰을 서버에 보내 통과시키기
    • 페이로드의 sub를 admin으로 바꿔 보내고, 서명 불일치로 거부당하기
    • 서버 시계를 30분 넘겨, 만료된 토큰으로 거부당하기
    프런트 다리 · 앱 보충디코딩과 검증은 다른 일
    프런트에서프런트에서 jwt-decode나 atob(token.split(".")[1])로 토큰 내용을 꺼내 화면에 표시
    Spring에서jjwt parseSignedClaims: 서명을 검증하면서 꺼내기
    같은 점꺼내는 페이로드는 같은 JSON이다.
    여기서 비유가 깨진다프런트의 디코딩은 서명을 확인하지 않으니 표시용으로만 쓸 수 있다. “이 사람은 관리자” 같은 판단은 키를 가진 서버가 서명을 검증한 다음에만 한다.

    출입증을 서버에 적어 두지 않는다는 것 — stateless 모델

    JWT의 진짜 매력은 구조가 아니라 운영 방식에 있다. 9장 끝에서 예고했듯, JWT는 stateless(상태 없음) 인증의 대표 선수다. 무슨 뜻일까?

    세션 방식(11장에서 자세히 본다)을 떠올려 보자. 그쪽은 서버가 “출입증 A-1234는 김아무개에게 발급됨”이라는 명단을 서버 안 어딘가에 적어 둔다. 요청이 올 때마다 그 명단을 뒤져 “A-1234가 명단에 있나?”를 확인한다. 반면 JWT는 그 명단을 두지 않는다. 출입증 자체에 “나는 김아무개, 만료는 3시”라고 적혀 있고 위조 방지 도장(서명)까지 찍혀 있으니, 서버는 명단을 뒤질 필요 없이 도장만 진짜인지 확인하면 끝이다.

    이게 왜 좋을까? 서버를 여러 대로 늘릴 때 빛을 발한다. 트래픽이 몰려 서버를 3대, 5대로 수평 확장한다고 해 보자. 세션 명단을 쓰면 그 명단을 모든 서버가 공유해야 한다(보통 Redis 같은 공유 저장소를 둔다). 반면 JWT는 출입증이 자기 정보를 다 들고 다니니, 어느 서버로 요청이 가든 그 서버 혼자 도장만 확인하면 된다. 공유 저장소가 필요 없다. 여러 프런트엔드와 모바일 앱이 같은 API를 두드리는 상황이라면 특히 잘 맞는다.

    예측 · 앱 보충먼저 답해 보세요

    사용자가 “모든 기기에서 지금 로그아웃”을 눌렀다. 이미 발급한 JWT를 서버가 즉시 무효화하기 쉬울까?

    프런트의 오랜 숙제 — 토큰을 어디에 둘까

    여기서 짚고 갈 게 하나 있다. 서버가 발급한 토큰을 프런트 개발자인 당신은 브라우저의 어디에 저장할 것인가? 흔한 후보는 둘이다. 자바스크립트로 자유롭게 읽고 쓰는 localStorage냐, 자바스크립트가 손대지 못하도록 봉인하는 httpOnly 쿠키냐.

    지금 이 자리에서 “무조건 이게 정답”이라고 못 박진 않겠다. 둘 중 무엇을 고르느냐는 XSS·CSRF 같은 공격과 5장에서 씨름한 CORS의 allowCredentials 설정까지 얽힌 보안 결정이다. 게다가 JWT냐 세션이냐의 선택과도 맞물려 있어서, 11장에서 트레이드오프를 정면으로 펼쳐 놓고 함께 판단하려 한다. 다만 오늘은 이것만 기억해 두자. 토큰을 어디 두느냐는 프런트의 사소한 구현 디테일이 아니라 보안 결정이다. (프런트 ↔︎ 백엔드 대응이 헷갈리면 부록 A 대조표의 ‘토큰 저장’ 항목을 함께 보자.) 이 장의 코드 예시는 설명을 단순하게 하려고 Authorization: Bearer 헤더 방식을 쓰지만, 그 선택의 무게는 잊지 말자.

    검문소를 세운다 — 필터체인에 JWT 인증 필터 끼우기

    이제 9장에서 그린 그림을 코드로 옮길 차례다. 할 일을 큰 그림으로 먼저 보자.

    1. 사용자가 아이디·비밀번호로 로그인하면, 서버가 검증 후 JWT를 발급해 돌려준다.
    2. 그 뒤로 사용자는 모든 요청의 Authorization 헤더에 Bearer {토큰}을 실어 보낸다.
    3. 9장의 필터체인 어딘가에 JWT 검증 필터를 끼워, 요청이 컨트롤러에 닿기 전에 토큰을 확인한다.
    4. 토큰이 유효하면 “이 요청은 김아무개가 보냈다”는 사실을 Security에 등록하고 통과시킨다.

    3번이 9장과 10장을 잇는 핵심이다. 9장에서 “검문소의 줄”을 그렸다면, 오늘은 그 줄에 우리가 만든 검문소 하나를 직접 끼워 넣는다.

    먼저 토큰을 발급하고 검증하는 도구가 필요하다. JWT는 라이브러리로 다루는데, 자바 진영에서는 jjwt나 nimbus-jose-jwt가 널리 쓰인다. 토큰을 만들고 푸는 작은 부품을 하나 만들어 보자.

    @Component
    public class JwtTokenProvider {
    
        private final SecretKey key;
        private final long validityMs = 1000L * 60 * 30; // 30분짜리 짧은 토큰
    
        public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
            this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        }
    
        // 로그인 성공 시 토큰 발급
        public String createToken(String username) {
            Date now = new Date();
            return Jwts.builder()
                .subject(username)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + validityMs))
                .signWith(key)
                .compact();
        }
    
        // 요청에 실려 온 토큰에서 사용자 식별
        public String getUsername(String token) {
            return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
        }
    }

    코드를 한 줄씩 곱씹어 보자. createToken은 로그인에 성공했을 때 불린다. “이 토큰의 주인은 username이다(subject)”, “발급 시각은 지금”, “30분 뒤 만료” 같은 정보를 담고, 마지막에 우리만 아는 비밀 키로 도장을 찍은 뒤(signWith) compact()로 한 줄 문자열로 압축한다. 반대로 getUsername은 들어온 토큰을 verifyWith로 서명을 검증하면서 풀어 페이로드의 주인을 꺼낸다. 서명이 안 맞으면 여기서 예외가 터지고, 그건 곧 “위조된 토큰”이라는 뜻이다.

    한 가지 일러둘 게 있다. 위 코드는 jjwt 0.12.x 계열의 빌더·파서 API다(Jwts.builder().subject(), Jwts.parser().verifyWith().build().parseSignedClaims()). 그런데 jjwt는 0.11→0.12에서 이 API가 크게 바뀌었다. 인터넷 예제와 AI는 구버전(Jwts.parserBuilder(), setSubject() 등)을 섞어 주기 쉬우니, 이 점은 잠시 뒤 신선도 함정 절에서 다시 짚는다.

    원문 보정 · 앱 보충

    원문에는 jwt.secret 설정이 없다. application.yml에 jwt.secret: …을 넣어야 이 빈이 만들어지고, Keys.hmacShaKeyFor는 키가 256비트(영문 32자) 미만이면 WeakKeyException을 던진다. signWith(key)는 키 길이에 맞춰 HS256(32바이트 이상)·HS384(48)·HS512(64)를 고른다. 의존성은 io.jsonwebtoken:jjwt-api:0.12.x에 런타임용 jjwt-impl과 jjwt-jackson까지 셋이 필요하다.

    로컬 실습 · 앱 보충ch10-1 출입증 발급기 JwtTokenProvider
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew test --tests '*JwtTokenProviderTest' -i --rerun
    token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcmEiLCJpYXQiOjE3OTEzNTczNTUsImV4cCI6MTc5MTM1OTE1NX0.vnVCMm7Eq_TN5FuL03SsqjnGGJFJ8nIB-ySGjtPKeFU
    header  = {"alg":"HS256"}
    payload = {"sub":"ara","iat":1791357355,"exp":1791359155}
    

    점(.)으로 나뉜 세 조각이 헤더·페이로드·서명이다. 앞의 둘은 Base64URL로 인코딩만 했을 뿐 암호화가 아니다. 누구나 풀어 읽을 수 있다. 출력된 토큰을 책 페이지의 디코더에 붙여 넣어 sub·iat·exp를 읽어 보자. exp - iat가 1800초(30분)다.

    테스트 셋이 각각 확인하는 것:

    • 만든 토큰을 다시 읽으면 ara가 나온다
    • 다른 키로 서명한 토큰은 SignatureException으로 거부된다. 페이로드를 읽을 수는 있어도 위조는 못 한다
    • 키가 짧으면 WeakKeyException

    signWith(key)는 키 길이로 알고리즘을 고른다. 38바이트 키라 HS256이다(48바이트 이상이면 HS384, 64바이트 이상이면 HS512).

    직접 깨뜨려 보기: 짧은 키

    application.yml의 jwt.secret을 too-short로 바꾸고 ./gradlew bootRun을 실행한다. 빈을 만들다 시작이 실패한다.

    WeakKeyException: The specified key byte array is 72 bits which is not secure enough for any JWT HMAC-SHA algorithm.
    

    확인했으면 git checkout -- .으로 되돌린다.

    다음은 이 부품을 요청마다 호출할 필터다. 9장에서 말한 “검문소” 그 자체다.

    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtTokenProvider tokenProvider;
    
        public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
            this.tokenProvider = tokenProvider;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain)
                throws ServletException, IOException {
    
            String header = request.getHeader("Authorization");
            if (header != null && header.startsWith("Bearer ")) {
                String token = header.substring(7);
                String username = tokenProvider.getUsername(token); // 유효하지 않으면 예외
                var auth = new UsernamePasswordAuthenticationToken(
                    username, null, List.of()); // 권한은 단순화
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
            chain.doFilter(request, response); // 다음 검문소로 넘긴다
        }
    }

    복잡해 보이지만 하는 일은 9장에서 그린 그대로다. Authorization 헤더에서 Bearer 뒤의 토큰을 떼어 내고, 아까 만든 JwtTokenProvider로 검증하고, 통과하면 “이 요청은 이 사용자가 보냈다”를 SecurityContextHolder에 등록한다. 이렇게 등록해 두면 그다음 인가 필터가 “이 사용자가 이 경로에 들어갈 권한이 있나”를 따질 때 이 정보를 본다. 마지막 chain.doFilter로 요청을 다음 검문소에 넘기면, 요청은 줄을 따라 흘러간다. 참고로 HttpServletRequest를 비롯한 서블릿 API는 Spring Security 6.x에서 javax.servlet이 아니라 jakarta.servlet 네임스페이스다. 이 책이 거듭 경고해 온 javax→jakarta 함정이 여기서도 그대로 적용된다.

    예측 · 앱 보충먼저 답해 보세요

    Authorization 헤더 없이 /api/tasks를 부르면 이 필터는 무엇을 할까?

    프런트 다리 · 앱 보충앞 검문소가 적어 두고 뒤에서 읽는다
    프런트에서Express 미들웨어에서 req.user = decoded로 심어 두고 뒤 핸들러가 읽기
    Spring에서SecurityContextHolder.getContext().setAuthentication(auth)
    같은 점앞단 검문소가 “누가 보냈나”를 요청 범위에 적어 두고, 뒤쪽 필터와 컨트롤러가 그걸 읽는다.
    여기서 비유가 깨진다Express는 req 객체에 직접 붙인다. Spring은 요청을 처리하는 스레드에 묶인 SecurityContext에 담고 요청이 끝나면 비운다. STATELESS라 다음 요청으로 이어지지 않으니, 매 요청 토큰을 다시 검사한다.

    신선도 함정의 클라이맥스 — AI가 들고 오는 구버전 코드

    자, 이제 이 책 전체를 관통해 온 이야기의 정점이다. 2장에서 개념으로 심고, 3장과 6장에서 import 한 줄로 맛본 그 신선도 함정을 오늘 정면으로 터뜨린다.

    당신이 Cursor나 Claude Code에게 이렇게 부탁했다고 해 보자. “Spring Security로 JWT 인증 설정을 만들어줘.” 십중팔구 AI는 아주 자신 있게 이런 코드를 내놓을 것이다.

    // ⚠️ AI가 자신 있게 주는 구버전 코드 (Spring Security 5.x 이하)
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .csrf().disable()
                .authorizeRequests()
                    .antMatchers("/api/auth/**").permitAll()
                    .anyRequest().authenticated()
                .and()
                .addFilterBefore(jwtFilter(),
                    UsernamePasswordAuthenticationFilter.class);
        }
    }
    예측 · 앱 보충먼저 답해 보세요

    이 코드를 Spring Boot 3.5(Spring Security 6.x) 프로젝트에 그대로 붙여 넣으면?

    로컬 실습 · 앱 보충ch10-2 AI가 준 구버전 SecurityConfig (컴파일 실패가 정상)
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew compileJava

    이 단계는 컴파일이 실패한다. 오류 5개, 경고 2개다. 하나씩 읽는다.

    SecurityConfig.java:6: error: cannot find symbol
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
      symbol:   class WebSecurityConfigurerAdapter
    
    SecurityConfig.java:13: error: cannot find symbol
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
      symbol: class WebSecurityConfigurerAdapter
    
    SecurityConfig.java:15: error: method does not override or implement a method from a supertype
        @Override
    
    SecurityConfig.java:18: warning: [removal] csrf() in HttpSecurity has been deprecated and marked for removal
    SecurityConfig.java:19: warning: [removal] authorizeRequests() in HttpSecurity has been deprecated and marked for removal
    
    SecurityConfig.java:20: error: cannot find symbol
                    .antMatchers("/api/auth/**").permitAll()
      symbol:   method antMatchers(String)
    
    SecurityConfig.java:23: error: cannot find symbol
                .addFilterBefore(jwtFilter(),
      symbol:   method jwtFilter()
    
    오류이유
    WebSecurityConfigurerAdapter 없음 (6·13행)Spring Security 6에서 삭제됐다. 상속 대신 SecurityFilterChain 빈을 등록한다
    @Override 대상 없음부모 클래스가 없으니 덮어쓸 configure도 없다
    antMatchers 없음6에서 삭제됐다. requestMatchers를 쓴다
    csrf() 경고인자 없는 체이닝 방식은 제거 예정이다. 람다 DSL csrf(csrf -> csrf.disable())를 쓴다
    authorizeRequests() 경고아직 남아 있지만 제거 예정이다. authorizeHttpRequests를 쓴다
    jwtFilter() 없음AI가 어디에도 정의하지 않은 메서드를 불렀다

    csrf()와 authorizeRequests()는 오류가 아니라 경고다. 6.x에도 아직 남아 있어 컴파일은 되지만 제거 예정이다. 컴파일된다고 다 맞는 것은 아니다.

    하나 더 있다. 이 파일이 통째로 바뀌면서 9장에서 넣은 PasswordEncoder 빈이 사라졌다. 컴파일러는 이걸 알려 주지 않는다. AI가 파일 전체를 다시 써 줄 때는 diff로 무엇이 빠졌는지 꼭 본다.

    표의 항목들은 부록 B 신선도 체크리스트가 걸러 내라는 구버전 패턴 그대로다. 다음 단계의 diff가 이 표의 before/after다.

    이 코드를 본 순간, 9장을 함께 걸어온 당신이라면 어딘가 낯설다는 느낌이 들어야 한다. 9장에서 쓴 건 분명 SecurityFilterChain 빈이었는데, 여기엔 그게 없다. 대신 WebSecurityConfigurerAdapter라는 걸 상속하고 configure 메서드를 오버라이드하고 있다. 게다가 authorizeRequests니 antMatchers니 하는 이름들은 9장에서 “이건 옛 이름”이라고 예고했던 바로 그것이다.

    왜 이런 일이 벌어질까? AI는 인터넷에 쌓인 방대한 예제를 학습한다. 그런데 Spring Security 5.x 시절의 이 패턴이 수년간 너무도 널리 쓰여, 인터넷에 압도적으로 많이 남아 있다. AI는 “더 흔한 패턴”을 자신 있게 줄 뿐, “더 최신인 패턴”을 가려내 주지 못한다. 그러니 AI가 자신 있다고 해서 옳은 게 아니다. 이 한 문장이 이 책이 거듭 강조해 온 핵심이다.

    그렇다면 무엇이 잘못됐고, 어떻게 고쳐야 할까? Spring Boot 3.x에 들어 있는 Spring Security 6.x에서는 이렇게 달라졌다.

    • WebSecurityConfigurerAdapter는 아예 제거됐다. 상속할 클래스 자체가 없다. 이 코드는 6.x에서 컴파일조차 되지 않는다.
    • authorizeRequests는 authorizeHttpRequests로 바뀌었다.
    • antMatchers(와 mvcMatchers)는 requestMatchers로 통합됐다.
    • .and()로 줄줄이 잇던 옛 방식 대신, 람다 DSL로 각 설정을 블록으로 받는다.

    같은 의도를 6.x로 다시 쓰면 이렇게 된다.

    // ✅ Spring Security 6.x — SecurityFilterChain 빈 + 람다 DSL
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        private final JwtAuthenticationFilter jwtFilter;
    
        public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
            this.jwtFilter = jwtFilter;
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .csrf(csrf -> csrf.disable()) // JWT는 stateless라 보통 끈다(쿠키 인증이면 재고)
                .sessionManagement(sm -> sm
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/auth/**").permitAll()
                    .requestMatchers("/api/tasks/**").authenticated()
                    .anyRequest().permitAll()
                )
                .addFilterBefore(jwtFilter,
                    UsernamePasswordAuthenticationFilter.class);
    
            return http.build();
        }
    }

    before와 after를 나란히 두고 보면 차이가 또렷하다. 9장에서 우리가 이미 SecurityFilterChain 빈을 썼기 때문에, after 쪽이 오히려 훨씬 익숙하게 읽힐 것이다. 9장에서 그려 둔 그림에 인증 필터 한 줄(addFilterBefore)을 더했을 뿐이다. 새로 담긴 의도는 둘이다. JWT는 stateless이니 서버 세션을 만들지 않도록 STATELESS로 못 박았고, /api/auth/**(로그인) 경로는 열어 두되 /api/tasks/**는 인증을 요구했다.

    원문 보정 · 앱 보충

    SecurityConfig가 생성자로 JwtAuthenticationFilter를 받으려면 그 필터가 빈이어야 한다. 원문의 필터 클래스에는 @Component가 없어서, 이대로면 시작할 때 JwtAuthenticationFilter 타입의 빈을 찾지 못해 실패한다. 필터 클래스에 @Component를 붙이자. (Filter 빈은 Spring Boot가 서블릿 필터로도 자동 등록하지만, OncePerRequestFilter라 한 요청에서 두 번 돌지는 않는다.)

    기억해 두자. 보안 설정만큼은 AI에게 받은 코드를 그대로 믿어선 안 된다. 이 셋만 걸러도 가장 흔한 함정은 피한다. 구체적인 점검 습관은 아래 AI 페어코딩 사이드바에 정리해 두었으니 곁에 두고 쓰자.

    원문 보정 · 앱 보충

    “이 셋”이 무엇인지 원문에 나오지 않는다. 위의 6.x 변경 목록과 아래 사이드바의 점검 항목은 둘 다 네 가지다. 사이드바의 네 가지(① WebSecurityConfigurerAdapter 상속, ② authorizeRequests·antMatchers 같은 옛 이름, ③ jjwt 구버전 빌더, ④ javax.* import)를 걸러 내는 것으로 읽자.

    로컬 실습 · 앱 보충ch10-3 Spring Security 6.x 방식으로 고치기
    명령과 기대 출력 펼치기

    실행해 보기

    아직 로그인 엔드포인트가 없으니, ch10-1의 테스트로 토큰을 하나 만든다.

    ./gradlew test --tests '*JwtTokenProviderTest' -i --rerun | grep "token ="

    서버를 띄우고 토큰 없이, 토큰을 실어 각각 요청한다.

    ./gradlew bootRun
    curl -i http://localhost:8080/api/tasks                                  # 403
    curl -i -H "Authorization: Bearer <토큰>" http://localhost:8080/api/tasks  # 200

    토큰이 있으면 200, 없으면 403이다. 왜 401이 아니라 403인지는 ch10-5에서 본다.

    CSRF를 껐으니 이제 POST도 된다. 9장의 403과 비교해 보자.

    curl -i -H "Authorization: Bearer <토큰>" -X POST http://localhost:8080/api/tasks \
      -H "Content-Type: application/json" \
      -d '{"title": "토큰으로 만든 할 일"}'                                       # 201

    남은 문제: 엉터리 토큰은 500

    curl -i -H "Authorization: Bearer garbage.token.here" http://localhost:8080/api/tasks
    HTTP/1.1 500
    

    서버 로그:

    io.jsonwebtoken.MalformedJwtException: Malformed protected header JSON: …
    

    원서 필터는 "유효하지 않으면 예외"를 그대로 던진다. 잘못된 토큰은 서버 오류가 아니라 인증 실패다. ch10-5에서 고친다.

    함정 찾기 · 앱 보충신선도 함정 결정판: AI가 준 SecurityConfig + JwtUtil
    내가 AI에게 한 요청
    “Spring Security로 JWT 인증 설정이랑 토큰 유틸 만들어줘. /api/auth/**는 열고 나머지는 인증 필요.” (Spring Boot 3.5, jjwt 0.12 프로젝트)

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 5개입니다.

    로그인 엔드포인트와 보호된 엔드포인트

    마지막으로, 이 모든 걸 TaskBoard에 실제로 연결해 보자. 우선 토큰을 발급하는 로그인 엔드포인트가 필요하다.

    @RestController
    @RequestMapping("/api/auth")
    public class AuthController {
    
        private final AuthService authService; // 비밀번호 검증 + 토큰 발급
    
        public AuthController(AuthService authService) {
            this.authService = authService;
        }
    
        @PostMapping("/login")
        public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req) {
            String token = authService.login(req.username(), req.password());
            return ResponseEntity.ok(new TokenResponse(token));
        }
    }

    AuthService 안에서는 9장에서 심어 둔 PasswordEncoder(BCrypt)가 비로소 일한다. 사용자가 보낸 평문 비밀번호를 DB에 해싱돼 저장된 비밀번호와 passwordEncoder.matches(...)로 대조한다. 맞으면 JwtTokenProvider.createToken으로 토큰을 발급해 돌려준다. 9장에서 “다음 장에서 활약한다”던 BCrypt가 여기서 약속을 지키는 셈이다.

    이제 흐름 전체가 한 바퀴 돈다. 프런트는 /api/auth/login으로 아이디·비밀번호를 보내 토큰을 받고, 그 뒤 /api/tasks를 부를 때마다 Authorization: Bearer {토큰}을 실어 보낸다. 우리가 끼운 JwtAuthenticationFilter가 그 토큰을 검문하고, 통과한 요청만 컨트롤러에 닿는다. 토큰 없이 /api/tasks를 때리면? 9장에서 본 그대로, 컨트롤러 근처에도 못 가고 401을 받는다.

    로컬 실습 · 앱 보충ch10-4 로그인하면 토큰을 준다
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew bootRun
    curl -X POST http://localhost:8080/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"username": "ara", "password": "pass1234"}'
    {"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcmEiLCJpYXQiOjE3…"}
    

    받은 토큰으로 할 일 목록을 본다. 이제 테스트 없이도 흐름이 한 바퀴 돈다.

    curl -i -H "Authorization: Bearer <토큰>" http://localhost:8080/api/tasks    # 200

    토큰을 책 페이지의 디코더에 붙여 sub가 ara인지 보자.

    실패하는 경우도 보낸다.

    # 틀린 비밀번호
    curl -i -X POST http://localhost:8080/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"username": "ara", "password": "wrong"}'
    # 없는 사용자
    curl -i -X POST http://localhost:8080/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"username": "nobody", "password": "pass1234"}'

    둘 다 똑같다.

    HTTP/1.1 401
    
    {"timestamp":"…","status":401,"message":"아이디 또는 비밀번호가 올바르지 않습니다.","fieldErrors":{}}
    

    빈 값을 보내면 5장의 검증이 400으로 막는다.

    {"timestamp":"…","status":400,"message":"입력값 검증에 실패했습니다.","fieldErrors":{"password":"공백일 수 없습니다","username":"공백일 수 없습니다"}}
    

    /api/auth/**는 permitAll이라 토큰 없이 부를 수 있다. 로그인 창구가 잠겨 있으면 아무도 토큰을 받을 수 없다.

    예측 · 앱 보충먼저 답해 보세요

    이 장의 SecurityConfig를 그대로 쓰고 토큰 없이 GET /api/tasks를 부르면, 실제로 돌아오는 상태 코드는?

    원문 보정 · 앱 보충

    9장 설정에는 httpBasic이 있어서 ExceptionTranslationFilter가 Basic 인증의 진입점(401)을 썼다. 이 장의 설정에는 httpBasic·formLogin 같은 인증 방식이 하나도 없어서, Spring Security가 기본 진입점인 Http403ForbiddenEntryPoint를 쓴다. 그래서 익명 요청도 403을 받는다. 401을 돌려주려면 설정에 .exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))를 더하자. 프런트가 401과 403을 구분해 “다시 로그인”과 “권한 없음”을 나눠 처리하려면 이 한 줄이 필요하다.

    로컬 실습 · 앱 보충ch10-5 403이 아니라 401로, 엉터리 토큰도 401로
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew bootRun
    TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"username": "ara", "password": "pass1234"}' | sed 's/.*"token":"\([^"]*\)".*/\1/')
     
    curl -i http://localhost:8080/api/tasks                                        # 401 (토큰 없음)
    curl -i -H "Authorization: Bearer garbage.token.here" http://localhost:8080/api/tasks  # 401 (형식 오류)
    curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/tasks       # 200

    서명만 바꾼 위조 토큰도 401이다. 토큰의 마지막 조각(서명)의 글자를 몇 개 바꿔 보내 보자. 페이로드는 그대로 읽히지만 서명이 맞지 않는다.

    ch10-3에서 500이던 엉터리 토큰이 이제 401이다. 서버 로그에 스택 트레이스도 남지 않는다.

    마무리

    오늘은 9장에서 그린 검문소의 줄에 진짜 인증 필터를 끼워 stateless 로그인을 직접 완성했다. JWT는 헤더·페이로드·서명 세 조각으로 이루어지고(RFC 7519), 서명은 위조를 막는 도장이며, 명단을 서버에 두지 않아 수평 확장에 강하다. 그 대가로 즉시 무효화가 까다롭다는 점까지 보았다. 무엇보다 AI가 자신 있게 들고 오는 WebSecurityConfigurerAdapter 구버전 코드를 6.x 람다 DSL로 고쳐내는 before/after를 직접 겪었다. 이 책이 처음부터 깔아 온 신선도 서사가 여기서 정점을 찍었다.

    기억해 두자. AI의 자신감은 정확성의 보증이 아니다. 특히 Security처럼 버전 경계가 뚜렷한 영역일수록, “이거 최신 맞아?”를 묻는 당신의 눈이 마지막 방어선이다.

    그런데 JWT가 정말 유일한 답일까? “이 사용자 지금 당장, 모든 기기에서 로그아웃시켜”가 필요해지는 순간, 명단 없는 stateless의 매력은 곧장 골칫거리로 바뀐다. 그렇다면 다른 길은 없을까? 다음 장에서는 정반대의 선택 — 세션(stateful) 방식을 같은 TaskBoard에 두 번째로 적용해 보고, 두 길의 트레이드오프를 정면으로 견줘 본다. 오늘 만든 출입증을 손에 쥔 채, 다른 길로 한번 걸어가 보자.

    확인 문제 · 앱 보충장을 덮기 전에 떠올려 보기

    1. JWT 페이로드에 비밀번호를 넣으면 안 되는 이유는?

    2. 페이로드의 sub를 고친 토큰을 보내면 서버는?

    3. Spring Boot 3.x에서 컴파일조차 안 되는 것은?

    4. jjwt 0.12에서 서명을 검증하며 토큰을 푸는 체인은?

    로컬 실습 · 앱 보충TaskBoard 레포로 단계별 실습하기

    이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.

    git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
    cd toby-react-to-spring-taskboard
    git checkout ch10-1
    1. ch10-1출입증 발급기 JwtTokenProvider
    2. ch10-2AI가 준 구버전 SecurityConfig (컴파일 실패가 정상)
    3. ch10-3Spring Security 6.x 방식으로 고치기
    4. ch10-4로그인하면 토큰을 준다
    5. ch10-5403이 아니라 401로, 엉터리 토큰도 401로

    이 장을 언급한 노트

    다음11장. 다른 길 — 세션같은 TaskBoard에 세션을 붙여 보고, JWT와의 트레이드오프를 정면으로 견줍니다.

    원문: 『React에서 Spring으로』 10장. JWT로 인증하기, Toby-AI · CC BY-NC-SA 4.0. 이 페이지는 원문에 실습 블록을 더하고, 기술 내용은 그대로 둔 채 한국어 문장을 읽기 쉽게 다듬은 2차 저작물이며 같은 라이선스를 따릅니다. ‘앱 보충’ 표시가 붙은 블록은 원문에 없는 내용입니다.

    보기 옵션