Spring Security + JWT 구현 가이드

김소희·2025년 10월 29일

1. JWT Secret Key & Properties

JWT는 서버가 직접 세션을 들고 있지 않고,
서버는 토큰의 서명(Signature)이 내가 발급한 것인지 를 검증한다.

그래서 secret key 의 품질/관리 방식이 매우 중요하다.

  • 예측 불가능한 문자열
  • 길이가 충분히 길어야 함
  • 코드에 직접 하드코딩하면 안 됨
  • 환경 설정(ex. properties)로 외부화 해야 함

실서비스에서는 보통 Vault / KMS / 환경변수 등을 사용하고
개발 단계에서는 일단 application.properties 로 보관할 수 있다.

application.properties 예시

spring.application.name=security_jwt_5
server.port = 8090

com.example.demo.secret-key=I40e<x)}8ov9"bNltNvTGjY'j{v@+E6A&s$*PJR:nlHLxX]^QNu&3Lz[w0m64k,(

# DB
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/kosa
spring.datasource.username=kosa
spring.datasource.password=1004

mybatis.type-aliases-package=com.example.demo.domain
mybatis.mapper-locations=classpath:mybatis/mapper/*.xml

logging.level.org.mybatis=DEBUG
logging.level.com.example.demo.mapper=DEBUG

이 secret key 문자열이 바로
JWT 생성(signWith) 및 검증(verifyWith) 단계에서 사용된다.


2. Gradle 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'

    // DB
    implementation 'org.mariadb.jdbc:mariadb-java-client:3.3.3'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'

    // JWT
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

3. JWT (JSON Web Token)

세션 기반 인증에서는 서버가 인증 상태(로그인 여부)를 직접 가지고 있다.
클라이언트는 단순히 JSESSIONID 쿠키만 들고 다니고, 그 값으로 서버가 세션을 찾아서 인증 여부를 확인하는 방식이다.
즉 인증 상태가 서버 쪽에 보관되기 때문에 구조가 Stateful 하다.

반대로 JWT 방식은 서버가 인증 상태를 기억하지 않는다.
사용자의 인증 정보는 토큰 내부(payload)에 들어있고, 그 토큰을 클라이언트가 직접 들고 요청마다 전송한다.
서버는 “이 토큰이 내가 서명한 토큰인가?” — 즉 위조 여부만 검증하면 된다.
인증 상태 자체를 서버가 유지하지 않기 때문에 Stateless하다.

JWT 구조

JWT는 Header.Payload.Signature로 구성된다.

구성설명
Header토큰 타입, 알고리즘
Payloaduid, 권한 등의 claim
Signature서버 비밀키로 생성한 서명

Header와 Payload는 Base64 URL-safe 인코딩이므로 디코딩이 가능하다. 하지만 Signature는 서버의 secret key 없이는 위조가 불가능하다.

JWT 검증은 "서버가 서명한 토큰인지"를 signature로 확인하는 과정이다.

보안 고려사항

JWT는 탈취되면 무효화가 어렵다. 운영 환경에서는 다음을 고려한다:

  • Access Token 만료 시간을 짧게 설정
  • Refresh Token을 별도 저장소(Redis/DB)에서 관리
  • 키 로테이션 전략 수립

4. SecurityConfig

세션을 비활성화하고 JWT 필터를 Security FilterChain에 추가한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CorsConfigurationSource corsConfigurationSource;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource))
            .csrf(c -> c.disable())
            .formLogin(c -> c.disable())
            .httpBasic(c -> c.disable())
            .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/user/**").hasRole("USER")
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().permitAll()
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
        return cfg.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new CustomerUserDetailsService();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}

권한 설정

hasRole("USER")는 USER 권한을 가진 사용자만 허용한다. hasRole("ADMIN")은 ADMIN 권한만 허용한다.

특정 권한을 제외하려면 다음과 같이 작성한다:

.requestMatchers("/user/**").access("hasRole('USER') and !hasRole('ADMIN')")

이 설정은 ROLE_ADMIN 사용자가 /user/** 경로에 접근하는 것을 막는다.

필터 실행 순서

addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)는 JWT 인증 필터를 UsernamePasswordAuthenticationFilter보다 먼저 실행한다.

  • UsernamePasswordAuthenticationFilter: 로그인(POST /login 등) 시에만 실행된다
  • JwtAuthenticationFilter: 모든 요청마다 실행되어 JWT를 확인하고 인증을 처리한다

요청 흐름

[클라이언트 요청]
  → FilterChain 시작
    → JwtAuthenticationFilter (JWT 검증)
      → Authorization 헤더 확인
        → Bearer {token} 파싱
          → 토큰 유효성 검사
            → 인증 정보를 SecurityContextHolder에 저장
              → UsernamePasswordAuthenticationFilter (필요 시)
                → DispatcherServlet
                  → Controller 실행

formLogin과 httpBasic을 비활성화하여 커스텀 /login 엔드포인트를 사용한다.


5. WebConfig (CORS)

CORS란?

CORS(Cross-Origin Resource Sharing)는 다른 출처(도메인)에서 리소스를 요청할 때 발생하는 보안 정책이다.

브라우저는 보안상 같은 출처의 리소스만 접근을 허용한다. 예를 들어:

  • 백엔드: http://localhost:8080
  • 프론트엔드: http://localhost:5173

이 경우 포트가 다르므로 다른 출처로 인식되어 CORS 에러가 발생한다.

언제 CORS 문제가 발생하나?

  • POSTMAN 테스트: 같은 도메인으로 간주되어 CORS 문제가 발생하지 않는다
  • React, Vue 등 프론트 서버: 별도 포트에서 실행되므로 CORS 문제가 발생한다

프론트엔드 서버 주소를 허용 목록에 추가해야 정상적으로 API를 호출할 수 있다.

@Configuration
public class WebConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                    .allowedOrigins("http://localhost:5173") // 프론트 서버 주소
                    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                    .allowedHeaders("*")
                    .exposedHeaders("Authorization")
                    .allowCredentials(true);
            }
        };
    }
}

exposedHeaders("Authorization")를 설정해야 클라이언트가 응답 헤더의 Authorization 값을 읽을 수 있다. 이 설정을 빼먹으면 브라우저에서 토큰을 확인할 수 없다.


6. SecurityConstants

문자열 리터럴을 상수화하여 오타를 방지한다.

1. Static 상수 방식

public final class SecurityConstants {
    public static final String HEADER_NAME = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer "; // 공백 포함
    public static final String TOKEN_TYPE = "JWT";

    private SecurityConstants() {}
}

2. Enum 방식

public enum SecurityConstants {
    HEADER_NAME("Authorization"),
    TOKEN_PREFIX("Bearer "),
    TOKEN_TYPE("JWT");

    private final String value;

    SecurityConstants(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

Bearer란?

Bearer는 토큰 인증 방식의 한 종류를 식별하는 키워드다. Bearer는 영어로 "소지자(보유자)"라는 뜻이다. 즉, 이 토큰을 가진 사람(Bearer)이 곧 인증된 사용자라는 의미다.

TOKEN_PREFIX는 공백을 포함한 "Bearer "로 정의한다. 이를 빼먹으면 substring 계산이 틀어진다.


7. JwtProps

Properties 파일의 secret key를 자바 객체로 가져온다.

@Component
@ConfigurationProperties("com.example.demo")
public class JwtProps {
    private String secretKey;

    public String getSecretKey() {
        return secretKey;
    }

    public void setSecretKey(String secretKey) {
        this.secretKey = secretKey;
    }
}

application.propertiessecret-key가 자동으로 secretKey 필드에 매핑된다.


8. LoginController

로그인 시 JWT를 발급하고, 토큰 검증을 테스트한다.

@Slf4j
@RestController
public class LoginController {
    
    @Autowired
    private JwtProps jwtProps;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    // JWT 발급
    @PostMapping("login")
    public ResponseEntity<String> login(@RequestBody AuthenticationRequest request) {
        String username = request.getUsername();
        String password = request.getPassword();
        
        log.info("username = {}", username);
        
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        if(userDetails == null || !passwordEncoder.matches(password, userDetails.getPassword())) {
            return new ResponseEntity<>("Invalid username or password", HttpStatus.UNAUTHORIZED);
        }
        
        List<String> roles = userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList();
        
        byte[] signingKey = jwtProps.getSecretKey().getBytes();

        String jwt = Jwts.builder()
            .signWith(Keys.hmacShaKeyFor(signingKey), Jwts.SIG.HS512)
            .subject(username)
            .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1시간
            .claim("rol", roles)
            .compact();
        
        return new ResponseEntity<>(jwt, HttpStatus.OK);
    }
    
    // JWT 파싱 테스트
    @GetMapping("user/info")
    public ResponseEntity<?> userInfo(@RequestHeader(name="Authorization") String header) {
        String secretKey = jwtProps.getSecretKey();
        byte[] signingKey = secretKey.getBytes();
        
        String jwt = header.replace(SecurityConstants.TOKEN_PREFIX, "").trim();
        
        Jws<Claims> parsedToken = Jwts.parser()
            .verifyWith(Keys.hmacShaKeyFor(signingKey))
            .build()
            .parseSignedClaims(jwt);
        
        Claims claims = parsedToken.getPayload();
        return new ResponseEntity<>(claims, HttpStatus.OK);
    }
}

UserDetailsService로 인증 정보를 조회하고, subject에 username을 저장한다. /user/info는 JWT 검증을 테스트하는 엔드포인트다.


9. Domain

AuthenticationRequest

로그인 요청 DTO다.

@Data
public class AuthenticationRequest {
    private String username;
    private String password;
}

User

DB 사용자 엔티티다.

@Data
public class User {
    private long id;
    private String username;
    private String password;
    private String role;
}

roleROLE_USER, ROLE_ADMIN 같은 Security 표준 prefix를 포함한다.


10. UserDetailsService

Spring Security가 인증 대상을 확인하기 위해 호출하는 서비스다.

@Service 
public class CustomerUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.findByUsername(username);
        
        if(user == null) {
            throw new UsernameNotFoundException("User not found: " + username);
        }
        
        List<GrantedAuthority> authorities = 
            List.of(new SimpleGrantedAuthority(user.getRole()));

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            authorities
        );
    }
}

DB에서 사용자를 조회하여 Security가 사용하는 UserDetails 객체로 변환한다. roleROLE_ prefix를 포함해야 한다.


11. JwtAuthenticationFilter

모든 요청을 Controller 진입 전에 가로채어 JWT를 검증한다.

역할

JWT가 발급된 사용자가 사이트에 접속할 때 비밀번호 확인 없이 토큰의 시그니처만 확인하여 통과시킨다. 토큰을 가진 사용자가 특정 주소를 요청하면 이 필터에서 검증한다.

동작 방식

  1. OncePerRequestFilter를 상속하므로 HTTP 요청마다 한 번만 실행된다
  2. Authorization: Bearer <JWT> 형식의 헤더에서 JWT를 추출하고 검증한다
  3. 유효한 JWT가 있는 경우 사용자 정보를 조회하고 인증을 처리한다
  4. 예외 URL(/login, /register)은 필터 적용에서 제외한다
  5. UserDetailsService를 사용하여 사용자 정보를 조회한다
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired 
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtProps jwtProps;
    
    private static final List<String> EXCLUDE_URLS = List.of("/login", "/register");

    private boolean shouldExclude(String uri) {
        return EXCLUDE_URLS.stream().anyMatch(uri::startsWith);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        
        String uri = req.getRequestURI();

        // 로그인, 회원가입은 제외
        if (shouldExclude(uri)) {
            chain.doFilter(req, res);
            return;
        }

        String header = req.getHeader(SecurityConstants.HEADER_NAME);
        
        if(header != null && header.startsWith(SecurityConstants.TOKEN_PREFIX)) {
            String jwt = header.substring(SecurityConstants.TOKEN_PREFIX.length());
            String secret = jwtProps.getSecretKey();

            try {
                var parsed = Jwts.parser()
                    .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                    .build()
                    .parseSignedClaims(jwt);

                String username = parsed.getPayload().getSubject();
                var user = userDetailsService.loadUserByUsername(username);
                var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());

                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (Exception e) {
                log.warn("JWT invalid: {}", e.getMessage());
            }
        }

        chain.doFilter(req, res);
    }
}

SecurityContextHolder.getContext().setAuthentication(auth)로 인증 상태를 Security에 알린다. 이것이 JWT 인증의 핵심이다.


12. UserMapper

MyBatis로 사용자를 조회하고 저장한다.

주의: 이 코드는 간단한 실습을 위해 @Mapper 인터페이스에 직접 SQL을 작성한 예외적인 방식이다.

원칙은 다음과 같다:

  • @Mapper 인터페이스: 추상 메서드만 선언
  • mapper.xml: SQL 쿼리 작성

실무에서는 인터페이스와 XML을 분리하여 사용하는 것을 권장한다.

@Mapper
public interface UserMapper {
    
    @Select("select id, username, password, role from user2 where username=#{username}")
    User findByUsername(String username);
    
    @Insert("insert into user2(username,password,role) values(#{username},#{password},#{role})")
    void saveUser(User user);
}

DB의 role 컬럼에는 ROLE_USER, ROLE_ADMIN 같은 prefix를 포함하여 저장한다.


13. 추가 고려사항

실서비스에서는 다음을 추가로 구현한다:

  • 다중 디바이스 로그인 시 기존 토큰 만료 처리
  • Remember-Me 자동 로그인
  • Refresh Token 저장소 (Redis)
  • Access Token 짧게, Refresh Token 길게 설정
  • 권한 변경 시 토큰 갱신 전략
  • 로그인 이력 저장
  • 보안 로깅, IP 제한

핵심은 Security FilterChain → JwtAuthenticationFilter → UserDetailsService의 연결이다.


참고 자료

profile
개발자 소희의 노트

0개의 댓글