JWT는 서버가 직접 세션을 들고 있지 않고,
서버는 토큰의 서명(Signature)이 내가 발급한 것인지 를 검증한다.
그래서 secret key 의 품질/관리 방식이 매우 중요하다.
실서비스에서는 보통 Vault / KMS / 환경변수 등을 사용하고
개발 단계에서는 일단 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) 단계에서 사용된다.
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'
}
세션 기반 인증에서는 서버가 인증 상태(로그인 여부)를 직접 가지고 있다.
클라이언트는 단순히 JSESSIONID 쿠키만 들고 다니고, 그 값으로 서버가 세션을 찾아서 인증 여부를 확인하는 방식이다.
즉 인증 상태가 서버 쪽에 보관되기 때문에 구조가 Stateful 하다.
반대로 JWT 방식은 서버가 인증 상태를 기억하지 않는다.
사용자의 인증 정보는 토큰 내부(payload)에 들어있고, 그 토큰을 클라이언트가 직접 들고 요청마다 전송한다.
서버는 “이 토큰이 내가 서명한 토큰인가?” — 즉 위조 여부만 검증하면 된다.
인증 상태 자체를 서버가 유지하지 않기 때문에 Stateless하다.
JWT는 Header.Payload.Signature로 구성된다.
| 구성 | 설명 |
|---|---|
| Header | 토큰 타입, 알고리즘 |
| Payload | uid, 권한 등의 claim |
| Signature | 서버 비밀키로 생성한 서명 |
Header와 Payload는 Base64 URL-safe 인코딩이므로 디코딩이 가능하다. 하지만 Signature는 서버의 secret key 없이는 위조가 불가능하다.
JWT 검증은 "서버가 서명한 토큰인지"를 signature로 확인하는 과정이다.
JWT는 탈취되면 무효화가 어렵다. 운영 환경에서는 다음을 고려한다:
세션을 비활성화하고 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보다 먼저 실행한다.
[클라이언트 요청]
→ FilterChain 시작
→ JwtAuthenticationFilter (JWT 검증)
→ Authorization 헤더 확인
→ Bearer {token} 파싱
→ 토큰 유효성 검사
→ 인증 정보를 SecurityContextHolder에 저장
→ UsernamePasswordAuthenticationFilter (필요 시)
→ DispatcherServlet
→ Controller 실행
formLogin과 httpBasic을 비활성화하여 커스텀 /login 엔드포인트를 사용한다.
CORS(Cross-Origin Resource Sharing)는 다른 출처(도메인)에서 리소스를 요청할 때 발생하는 보안 정책이다.
브라우저는 보안상 같은 출처의 리소스만 접근을 허용한다. 예를 들어:
http://localhost:8080http://localhost:5173이 경우 포트가 다르므로 다른 출처로 인식되어 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 값을 읽을 수 있다. 이 설정을 빼먹으면 브라우저에서 토큰을 확인할 수 없다.
문자열 리터럴을 상수화하여 오타를 방지한다.
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() {}
}
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)이 곧 인증된 사용자라는 의미다.
TOKEN_PREFIX는 공백을 포함한 "Bearer "로 정의한다. 이를 빼먹으면 substring 계산이 틀어진다.
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.properties의 secret-key가 자동으로 secretKey 필드에 매핑된다.
로그인 시 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 검증을 테스트하는 엔드포인트다.
로그인 요청 DTO다.
@Data
public class AuthenticationRequest {
private String username;
private String password;
}
DB 사용자 엔티티다.
@Data
public class User {
private long id;
private String username;
private String password;
private String role;
}
role은 ROLE_USER, ROLE_ADMIN 같은 Security 표준 prefix를 포함한다.
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 객체로 변환한다. role은 ROLE_ prefix를 포함해야 한다.
모든 요청을 Controller 진입 전에 가로채어 JWT를 검증한다.
JWT가 발급된 사용자가 사이트에 접속할 때 비밀번호 확인 없이 토큰의 시그니처만 확인하여 통과시킨다. 토큰을 가진 사용자가 특정 주소를 요청하면 이 필터에서 검증한다.
OncePerRequestFilter를 상속하므로 HTTP 요청마다 한 번만 실행된다Authorization: Bearer <JWT> 형식의 헤더에서 JWT를 추출하고 검증한다/login, /register)은 필터 적용에서 제외한다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 인증의 핵심이다.
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를 포함하여 저장한다.
실서비스에서는 다음을 추가로 구현한다:
핵심은 Security FilterChain → JwtAuthenticationFilter → UserDetailsService의 연결이다.