Spring 숙련 (JWT, Spring Security)

KimGwangmin·2026년 9월 15일

JWT(JSON Web Token)

정보를 JSON으로 표현해 전달하는 토큰 형식. Access Token이라고도 한다.

서버가 클라이언트의 세션 정보를 저장할 필요 없이(Stateless) 인증을 수행할 수 있는 방법의 필요성에 의해 제안됨

  • Header: 토큰 처리에 필요한 정보(서명 알고리즘 등)
  • Payload: 실제 데이터(회원 ID, 역할 등), 정보의 각 항목을 클레임(claim)이라고 함
  • Signature: 헤더 + 페이로드와 키를 이용해 만든 서명

Payload의 구성 요소

클레임의미
sub (subject)회원 ID이 주로 담김 (토큰의 주체)
iat (issued at)발급 시각
exp (expiration)만료 시각
role애플리케이션이 정한 사용자 역할. 표준 등록 클레임이 아닌 사용자 정의 항목
이 외에 필요한 데이터 key이 외에 필요한 데이터 value 추가 가능

Session vs JWT

JWT는 Stateless, Session은 Stateful

세션 키: 식별용 쓰레기값, 서버에서 세션 키를 보고 세션을 불러와야 그 안에 정보가 있음
JWT: 클라이언트가 보내는 토큰 안에 정보가 담겨있음. 서버는 별도 정보 없이 사용자 식별이 가능

서명과 검증

JWT 인코딩 & 디코딩 해보기

Signature = {암호화 알고리즘}(Base64Url(Header) + "." + Base64Url(Payload), SecretKey)
최종 JWT는 Base64Url(Header).Base64Url(Payload).Base64Url(Signature)로 만들어진다.

이건 '암호화'가 아니다! Signature는 Header와 Payload 뒤에 붙어 '이 내용을 내가 썼다'는 것을 보장해주는 수단일 뿐, 헤더와 페이로드는 Base64Url 디코딩 시 누구나 평문을 확인할 수 있다.

따라서 JWT에는 민감한 정보를 담아선 안된다.

Base64URL은 표준 Base64 인코딩 방식에서 URL, 파일 이름 등과 충돌하지 않도록 +, /, =을 처리하는 인코딩 변형이다.
어떤 문자열이든 URL에 안전하게 넣을 수 있도록 변환해주는 함수라고 생각하면 된다.

JWT 활용 실습

다음 의존성이 필요하다.

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-gson:0.12.6'

암호화 설정

@Configuration
@EnableMethodSecurity
public class AuthenticationConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

복습: @Component가 아니라 @Bean을 쓰는 이유
위 코드에서 passwordEncoder() 메서드는 앱 시작 시 스프링이 딱 한 번 호출해서 객체를 생성하는 팩토리 역할을 하고, 그 반환값인 BCryptPasswordEncoder 객체가 컨테이너에 PasswordEncoder 형태로 보관된다.
@Bean은 그 메서드를 빈에 등록하고 사용하는 게 아니라, 메서드의 반환 객체를 빈에 등록하는 것이다. 굳이 이런 식으로 우회하는 이유는, BCryptPasswordEncoder가 스프링 라이브러리 내부 클래스라 직접 @Component로 등록할 수 없기 때문이다.

JWT 발급

@Component
public class JwtProvider {
    private final SecretKey secretKey;
    private final long accessTtlSeconds; // TTL(Time To Live): 토큰 만료 시간 설정

    public JwtProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.access-ttl-seconds}") long accessTtlSeconds
    ) {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
        this.accessTtlSeconds = accessTtlSeconds;
    }

    public String createAccessToken(
            User user
    ) {
        Instant now = Instant.now();
        Instant expiresAt = now.plusSeconds(accessTtlSeconds);
        // 회원 정보와 만료 시각을 담아 JWT 발급 (페이로드, 서명 추가)
        return Jwts.builder()
                .subject(user.getId().toString())
                .claim("role", user.getRole().name()) // 사용자 지정 클레임
                .issuedAt(Date.from(now))
                .expiration(Date.from(expiresAt))
                .signWith(secretKey, Jwts.SIG.HS256) // 서명
                .compact();
    }

	// JWT 파싱 유틸리티 메서드
    public AuthUser parseToken(
            String token
    ) {
        try {
            // JWT 서명과 만료 검증
            Jws<Claims> signedClaims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
            Claims claims = signedClaims.getPayload();
            String subject = claims.getSubject();
            long userId = Long.parseLong(subject);
            Role role = Role.valueOf(claims.get("role", String.class));
            // 검증한 회원 정보로 인증 DTO 객체 생성
            return new AuthUser(userId, role);
        } catch (JwtException | IllegalArgumentException | NullPointerException exception) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
    }
}

서비스 구현

  • 인증 서비스
@Service
@RequiredArgsConstructor
public class CredentialService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional(readOnly = true)
    public User authenticate(
            LoginRequest request
    ) {
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
        String password = request.getPassword(); // 요청 비밀번호(평문)
        String encodedPassword = user.getPassword(); // DB 저장된 비밀번호(비문)
        if (!passwordEncoder.matches(password, encodedPassword)) { // 평문 비문 비교 (일치 확인)
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        return user;
    }
}
  • 로그인
@Service
@RequiredArgsConstructor
public class JwtAuthService {
    private final CredentialService credentialService;
    private final JwtProvider jwtProvider;

    @Transactional(readOnly = true)
    public AccessTokenResponse login(
            LoginRequest request
    ) {
        User user = credentialService.authenticate(request);
        String accessToken = jwtProvider.createAccessToken(user); // JWT 발급
        return new AccessTokenResponse(accessToken);
    }
}

Spring Security

스프링 보안 프레임워크
스프링 기반 애플리케이션에 인증, 인가 기능 제공

인증 Filter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
            filterChain.doFilter(request, response);
            return;
        }
        AuthUser authUser;
        try {
            String token = authorization.substring(7);
            authUser = jwtProvider.parseToken(token);
        } catch (ResponseStatusException exception) {
            SecurityContextHolder.clearContext();
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }
        SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + authUser.getRole().name());
        List<SimpleGrantedAuthority> authorities = List.of(authority);
        Authentication authentication = new JwtAuthenticationToken(authUser, authorities);
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);
        try {
            filterChain.doFilter(request, response);
        } finally {
            SecurityContextHolder.clearContext();
        }
    }
}
  • HTTP 요청 헤더에서 JWT를 꺼내(key = Authorization) 파싱
  • Bearer 접두사를 포함하는 토큰 검사
    • 없으면 다음 필터로 넘김
    • 있으면 접두사 문자열만 잘라냄
  • jwtProvider.parseToken(): JWT 토큰 만료 여부, 서명 검증
    • 인증 실패 시 401 Unauthorized 반환
    • 인증 성공 시 내부 데이터 추출해서 AuthUser 객체에 매핑
  • 유저 인증 및 권한("ROLE_USER", "ROLE_ADMIN" 등) 부여한 JwtAuthenticationToken(Authentication 객체) 생성
    • SecurityContext에 인증 객체를 등록해야 인증이 필요한 보안 영역(API, Controller 등)에 접근 가능
  • 인증 객체를 SecurityContextHolder에 등록
  • 요청 완료 후 SecurityContextHolder 정리

Spring Security Filter Chain

@Configuration
public class JwtSecurityConfig {
    @Bean
    public SecurityFilterChain jwtSecurityFilterChain(
            HttpSecurity http,
            JwtProvider jwtProvider
    ) throws Exception {
        http
        		// 세션 방식에 관련된 요소들을 전부 비활성화
                .csrf(AbstractHttpConfigurer::disable) // 쿠키 인증 없이 Bearer 헤더를 쓰므로 CSRF 보호 끄기
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 요청마다 JWT를 검증하므로 인증용 세션 사용 안 함
                .requestCache(AbstractHttpConfigurer::disable) // 로그인 후 이동할 페이지가 없어 요청 저장 끄기
                .formLogin(AbstractHttpConfigurer::disable) // JSON 로그인 API를 쓰므로 기본 로그인 폼 끄기
                .httpBasic(AbstractHttpConfigurer::disable) // JWT로 요청을 인증하므로 HTTP Basic 인증 끄기
                .logout(AbstractHttpConfigurer::disable) // JWT 인증을 사용하므로 세션 기반 로그아웃 끄기
                // 인가 설정
                // 스프링 시큐리티가 AuthorizationFilter를 생성하여 필터 체인 맨 끝에 등록
                .authorizeHttpRequests(authorize -> authorize
                        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
                        .requestMatchers(HttpMethod.GET, "/posts").permitAll()
                        .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/admin/signup").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                // 예외 처리
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, exception) ->
                                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
                        .accessDeniedHandler((request, response, exception) ->
                                response.setStatus(HttpServletResponse.SC_FORBIDDEN)))
				// 커스텀 필터 배치
                // 스프링 시큐리티 기본 인가 필터인 AuthorizationFilter 바로 앞에 배치
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), AuthorizationFilter.class);
        return http.build();
    }
}

JWT 인증이 필요한 요청

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @GetMapping("/me")
    public ResponseEntity<UserResponse> me(
            @AuthenticationPrincipal AuthUser authUser // 필터에서 만들어진 인증 객체에 들어있는 DTO를 꺼내서 매핑, 즉 인증된 유저의 요청만 받음
    ) {
        return ResponseEntity.ok(userService.findMe(user.getUserId()));
    }
}

Role 기반 인가

FilterChain을 사용한 Role 기반 인가

위의 시큐리티 설정 코드를 보면 다음과 같은 라인이 있다.

.requestMatchers("/admin/**").hasRole("ADMIN")

이는 /admin으로 시작하는 모든 API는 ROLE_ADMIN 역할이 있어야만 접근할 수 있도록 하겠다는 의미이다. 몇 가지 자주 쓰이는 검사 표현이 있다.

검사 표현비교하는 문자열
hasRole("ADMIN")ROLE_ADMIN
hasAuthority("ROLE_ADMIN")ROLE_ADMIN
hasRole("USER")ROLE_USER

역할의 접두사에 ROLE_을 요구하는 것은 Spring Security만의 고유 규칙이다.
hasRole은 접두사가 있는 것으로 간주하고, hasAuthority는 조금 더 일반적으로 문자열을 그대로 비교한다.

어노테이션을 사용한 Role 기반 인가

  • @EnableMethodSecurity가 필요 (위쪽의 AuthenticationConfig 참고)
  • @PreAuthorize를 이용해 권한 검사 가능
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> users() {
    return ResponseEntity.ok(userService.findAll());
}

접근 제어

요청 본문(@RequestBody)이나 URL 경로(@PathVariable)를 통해 사용자의 Id를 받을 경우, 클라이언트가 다른 회원의 Id를 넣어 그 사람 이름으로 글 작성, 삭제 등의 요청을 넣을 수 있다.

JWT 안에 해당 정보를 넣으면 위조가 불가능하다. (본인의 비밀 키로 서명했기 때문에)

컨트롤러 예제

@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;

    @PostMapping("/posts")
    public ResponseEntity<PostResponse> create(
            @AuthenticationPrincipal AuthUser authUser
    ) {
        PostResponse post = postService.create(authUser.getUserId());
        return ResponseEntity.status(HttpStatus.CREATED).body(post);
    }

    @GetMapping("/posts")
    public ResponseEntity<List<PostResponse>> posts() {
        return ResponseEntity.ok(postService.findAll());
    }

    @DeleteMapping("/posts/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable long id,
            @AuthenticationPrincipal AuthUser authUser
    ) {
        postService.delete(id, authUser.getUserId());
        return ResponseEntity.noContent().build();
    }
}

JWT로부터 추출한 AuthUser 객체로부터 id를 꺼내 서비스에 넘겨준다.

서비스 예제

@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;
    private final UserRepository userRepository;

    @Transactional
    public PostResponse create(
            long authenticatedUserId
    ) {
        User user = userRepository.findById(authenticatedUserId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
        Post post = new Post(user);
        Post savedPost = postRepository.save(post);
        return new PostResponse(savedPost.getId(), savedPost.getUser().getId());
    }

    @Transactional(readOnly = true)
    public List<PostResponse> findAll() {
        List<Post> posts = postRepository.findAll();
        return posts.stream()
                .map(post -> new PostResponse(post.getId(), post.getUser().getId()))
                .toList();
    }

    @Transactional
    public void delete(
            long postId,
            long authenticatedUserId
    ) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        if (post.getUser().getId() != authenticatedUserId) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        postRepository.delete(post);
    }
}

예제 실행 결과

가입

로그인

JWT 디코딩 해보기

헤더와 페이로드도 잘 복원되고, 우측 하단의 인증 역시 프로젝트의 환경 변수 시크릿 값을 넣으니 정상적으로 확인되었다.

내 정보 조회

JWT를 올바르게 포함한 경우JWT가 누락된 경우

관리자 API 호출

관리자 가입관리자 로그인
ADMIN 역할이 없을 때ADMIN 역할이 있을 때

0개의 댓글