[Spring] JWT 03. Interceptor로 인증 적용하기 (로그인 필요 API 보호)

[Spring] JWT 03. Interceptor로 인증 적용하기 (로그인 필요 API 보호)

JWT를 발급했다면 이제 “로그인 필요한 API”를 보호해야 합니다.
Spring에서는 보통 Security Filter를 많이 쓰지만, 프로젝트 구조에 따라 Interceptor로도 충분히 구현할 수 있습니다.
이번 글은 Interceptor 기반 JWT 인증 흐름을 정리합니다.


1. Interceptor로 JWT 인증을 하는 이유

  • 컨트롤러 실행 전에 요청을 가로채서 검사할 수 있음
  • 특정 URL 패턴만 “로그인 필요”로 지정 가능
  • JWT 파싱 후 loginId / 권한을 request에 담아 컨트롤러에서 사용 가능

핵심 목표는 딱 2가지입니다.

  1. Authorization 헤더에 토큰이 없거나 잘못되면 → 401 Unauthorized
  2. 정상 토큰이면 → claims(loginId, level)을 꺼내서 request에 저장

2. 요청 헤더에서 토큰이 어떻게 오나?

프론트는 보통 아래처럼 헤더에 JWT를 담아 보냅니다.

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....

서버는 이 헤더를 받아서 Bearer 를 제거하고 토큰을 검증합니다.


3. TokenService(토큰 검증/파싱) 예시

Interceptor는 “인증 검사 담당”, TokenService는 “JWT 파싱 담당”으로 역할을 나누면 관리가 편합니다.

import java.nio.charset.StandardCharsets;

import javax.crypto.SecretKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

@Service
public class TokenService {

    @Value("${custom.jwt.key-str}")
    private String keyStr;

    @Value("${custom.jwt.issuer}")
    private String issuer;

    public TokenVO parse(String authorization) {
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            throw new RuntimeException("NO_TOKEN");
        }

        String token = authorization.substring(7);

        SecretKey key = Keys.hmacShaKeyFor(keyStr.getBytes(StandardCharsets.UTF_8));

        Claims claims = (Claims) Jwts.parser()
                .verifyWith(key)
                .requireIssuer(issuer)
                .build()
                .parse(token)
                .getPayload();

        TokenVO vo = new TokenVO();
        vo.setLoginId((String) claims.get("loginId"));
        vo.setLoginLevel((String) claims.get("loginLevel"));
        return vo;
    }
}

TokenVO는 로그인 정보를 담는 DTO 역할입니다.

public class TokenVO {
    private String loginId;
    private String loginLevel;
    // getter/setter 생략
}

4. Interceptor 구현 (로그인 필요한 요청만 통과)

Interceptor에서 할 일은 아래 순서입니다.

  1. Authorization 헤더 꺼내기
  2. TokenService.parse()로 검증/파싱
  3. 성공하면 request attribute에 loginId, loginLevel 저장
  4. 실패하면 401 반환
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class MemberInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;

    public MemberInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        String authorization = request.getHeader("Authorization");

        try {
            TokenVO tokenVO = tokenService.parse(authorization);

            // 컨트롤러에서 꺼내 쓰도록 저장
            request.setAttribute("loginId", tokenVO.getLoginId());
            request.setAttribute("loginLevel", tokenVO.getLoginLevel());
            request.setAttribute("tokenVO", tokenVO);

            return true; // 통과
        }
        catch (Exception e) {
            // 토큰 없거나, 위조/만료/issuer 불일치 등
            response.setStatus(401);
            response.setCharacterEncoding("UTF-8");
            response.setContentType("application/json");
            response.getWriter().write("{\"message\":\"Unauthorized\"}");
            return false;
        }
    }
}

5. Interceptor 등록 (URL 패턴 지정)

Interceptor는 등록을 해야 동작합니다.
특정 URL만 보호하려면 addPathPatterns()에 패턴을 지정합니다.

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    private final MemberInterceptor memberInterceptor;

    public InterceptorConfiguration(MemberInterceptor memberInterceptor) {
        this.memberInterceptor = memberInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(memberInterceptor)
                .addPathPatterns(
                    "/api/mypage/**",
                    "/api/point/**",
                    "/api/admin/**"
                )
                .excludePathPatterns(
                    "/api/account/login",
                    "/api/account/join",
                    "/api/account/refresh"
                );
    }
}

이제 /api/mypage/** 같은 경로는 토큰 없으면 접근 불가입니다.


6. 컨트롤러에서 loginId 사용하기

Interceptor가 request에 저장한 loginId를 컨트롤러에서 꺼내 쓰면 됩니다.

@GetMapping("/api/mypage")
public String mypage(HttpServletRequest request) {
    String loginId = (String) request.getAttribute("loginId");
    String loginLevel = (String) request.getAttribute("loginLevel");

    // loginId 기준으로 DB 조회 등 처리
    return "OK : " + loginId + " (" + loginLevel + ")";
}

7. 실행 결과 (Output)

7-1) 토큰 없이 요청하면

GET /api/mypage
Authorization: (없음)

HTTP/1.1 401 Unauthorized
{"message":"Unauthorized"}

7-2) 정상 토큰으로 요청하면

GET /api/mypage
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....

HTTP/1.1 200 OK
OK : testuser1 (USER)

7-3) 만료된 토큰이면

HTTP/1.1 401 Unauthorized
{"message":"Unauthorized"}

만료되었을 때는 보통 프론트가 refresh API를 호출해서 새 AccessToken을 받은 뒤 재요청합니다.


8. 자주 하는 실수

  • Authorization 헤더에 Bearer 없이 토큰만 보내기 → 서버 파싱 실패
  • 모든 URL에 인터셉터 걸어버려서 로그인/회원가입/refresh도 막힘
  • 401을 받았을 때 프론트에서 refresh 로직이 없어서 “로그인 풀림”처럼 보임

마무리

Interceptor로 JWT 인증을 적용하면 “로그인 필요 API”를 쉽게 보호할 수 있습니다.
다음 글(4편)에서는 Token 만료 임박 시 자동 갱신(renewal) 또는 RefreshToken 재발급 흐름를 실제 운영 방식처럼 정리해보겠습니다.