
JWT(JSON Web Token)은 클라이언트와 서버 간 정보를 안전하게 전송하기 위한 토큰 기반 인증 방식입니다.
Header.Payload.Signature
로그인 → 서버에 세션 저장 → 세션 ID 쿠키 전달 → 요청마다 세션 조회
단점: 서버 메모리 부담, 다중 서버 환경 복잡
로그인 → JWT 발급 → 클라이언트 저장 → 요청마다 JWT 전송 → 서버는 검증만
장점: 서버 부담 감소, 확장성 우수
[회원가입]
Client → Controller → Service → BCrypt 암호화 → DB 저장
[로그인]
Client → Controller → AuthenticationManager → DB 조회 → 비밀번호 검증 → JWT 발급
[API 호출]
Client (Header: Authorization: Bearer JWT) → JWTFilter → JWT 검증 → Controller
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
spring.jwt.secret=your-secret-key-must-be-at-least-32-characters-long
@Entity
@Table(name = "USER")
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String password; // BCrypt 암호화
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private UserRole role; // ROLE_USER, ROLE_ADMIN
}
@Component
public class JWTUtil {
private final SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// JWT에서 username 추출
public String getUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseClaimsJws(token)
.getPayload()
.get("username", String.class);
}
// JWT에서 role 추출
public String getRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseClaimsJws(token)
.getPayload()
.get("role", String.class);
}
// JWT 만료 확인
public Boolean isTokenExpired(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseClaimsJws(token)
.getPayload()
.getExpiration()
.before(new Date());
}
// JWT 생성
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
핵심 포인트
@Slf4j
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
// Authorization 헤더에서 JWT 추출
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = authorizationHeader.substring(7);
try {
// JWT 만료 검증
if (jwtUtil.isTokenExpired(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT 토큰이 만료되었습니다.");
return;
}
// JWT에서 사용자 정보 추출
String username = jwtUtil.getUsername(token);
UserRole role = UserRole.valueOf(jwtUtil.getRole(token));
// 인증 객체 생성
User user = User.builder()
.username(username)
.password("N/A")
.role(role)
.build();
CustomUserDetails customUserDetails = new CustomUserDetails(user);
Authentication authToken = new UsernamePasswordAuthenticationToken(
customUserDetails, null, customUserDetails.getAuthorities());
// SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (Exception e) {
log.error("JWT 검증 실패: {}", e.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
return;
}
chain.doFilter(request, response);
}
}
동작 과정
1. Authorization 헤더에서 JWT 추출
2. JWT 만료 확인
3. username, role 추출
4. SecurityContext에 인증 정보 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
return request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setExposedHeaders(Collections.singletonList("Authorization"));
config.setMaxAge(3600L);
return config;
};
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/signUp", "/api/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts").authenticated()
.anyRequest().authenticated())
.addFilterBefore(new JWTFilter(jwtUtil),
UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
핵심 설정
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"사용자를 찾을 수 없습니다: " + username));
return new CustomUserDetails(user);
}
}
역할: 로그인 시 DB에서 사용자 조회
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Transactional
public void signUp(SignUpDto signUpDto) {
// 중복 체크
if (userRepository.findByUsername(signUpDto.getUsername()).isPresent()) {
throw new IllegalArgumentException("이미 사용중인 아이디입니다.");
}
// 비밀번호 암호화 후 저장
User user = User.builder()
.username(signUpDto.getUsername())
.password(bCryptPasswordEncoder.encode(signUpDto.getPassword()))
.role(UserRole.ROLE_USER)
.build();
userRepository.save(user);
}
}
핵심 로직
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
// 회원가입
@PostMapping("/api/signUp")
public ResponseEntity<String> signUp(@RequestBody SignUpDto signUpDto) {
userService.signUp(signUpDto);
return ResponseEntity.ok("회원가입 성공");
}
// 로그인
@PostMapping("/api/login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto dto) {
try {
// 인증 시도
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
dto.getUsername(), dto.getPassword())
);
// JWT 발급
CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal();
String username = principal.getUsername();
String role = authentication.getAuthorities()
.iterator().next().getAuthority();
String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L); // 1시간
return ResponseEntity.ok(new LoginResponseDto(token));
} catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("아이디 또는 비밀번호가 올바르지 않습니다.");
}
}
}
로그인 프로세스
1. AuthenticationManager가 비밀번호 검증
2. 성공 시 JWT 발급 (1시간 유효)
3. 실패 시 401 에러
Client → POST /api/signUp
→ UserService (중복체크 → BCrypt 암호화)
→ DB 저장
Client → POST /api/login
→ AuthenticationManager (비밀번호 검증)
→ JWT 발급
→ Client에 토큰 반환
Client → POST /api/posts (Header: Authorization: Bearer JWT)
→ JWTFilter (JWT 검증)
→ SecurityContext 설정
→ Controller
POST http://localhost:8080/api/signUp
Content-Type: application/json
{
"username": "jinu",
"password": "1234"
}
응답: 200 OK - 회원가입 성공
POST http://localhost:8080/api/login
Content-Type: application/json
{
"username": "jinu",
"password": "1234"
}
응답:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
POST http://localhost:8080/api/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"title": "jwt 공부 중",
"content": "재밌다"
}
// 서버는 토큰 저장 없이 검증만 수행
String token = jwtUtil.createJwt(username, role, expiredMs);
// 회원가입 시
String encoded = bCryptPasswordEncoder.encode("1234");
// DB: $2a$10$N9qo8uLOickgx2Z...
// 로그인 시
boolean matches = bCryptPasswordEncoder.matches("1234", encoded);
.addFilterBefore(new JWTFilter(jwtUtil),
UsernamePasswordAuthenticationFilter.class)
JWT 검증이 먼저 실행되어야 함