Spring Boot JWT 인증 구현

송진우·2025년 12월 18일
post-thumbnail

JWT란?

JWT(JSON Web Token)은 클라이언트와 서버 간 정보를 안전하게 전송하기 위한 토큰 기반 인증 방식입니다.

JWT 구조

Header.Payload.Signature
  • Header: 토큰 타입과 암호화 알고리즘
  • Payload: 실제 데이터(username, role 등)
  • Signature: 서명 값

🆚 세션 vs JWT

세션 기반 (Stateful)

로그인 → 서버에 세션 저장 → 세션 ID 쿠키 전달 → 요청마다 세션 조회

단점: 서버 메모리 부담, 다중 서버 환경 복잡

JWT 기반 (Stateless)

로그인 → JWT 발급 → 클라이언트 저장 → 요청마다 JWT 전송 → 서버는 검증만

장점: 서버 부담 감소, 확장성 우수


전체 구조

인증 흐름도

[회원가입]
Client → Controller → Service → BCrypt 암호화 → DB 저장

[로그인]
Client → Controller → AuthenticationManager → DB 조회 → 비밀번호 검증 → JWT 발급

[API 호출]
Client (Header: Authorization: Bearer JWT) → JWTFilter → JWT 검증 → Controller

구현 과정

1. 의존성 추가

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'
}

2. application.properties

spring.jwt.secret=your-secret-key-must-be-at-least-32-characters-long

3. User 엔티티

@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
}

4. JWTUtil (JWT 생성/검증)

@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();
    }
}

핵심 포인트

  • Payload에 username, role 저장
  • HMAC-SHA 알고리즘으로 서명
  • 만료 시간 설정 (1시간)

5. JWTFilter (JWT 검증 필터)

@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에 인증 정보 설정


6. SecurityConfig (Spring Security 설정)

@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();
    }
}

핵심 설정

  • CSRF 비활성화 (JWT 사용)
  • 엔드포인트별 권한 설정
  • JWT 필터 등록
  • Stateless 세션 정책

7. CustomUserDetailsService

@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에서 사용자 조회


8. UserService (회원가입)

@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);
    }
}

핵심 로직

  • 중복 username 체크
  • BCrypt로 비밀번호 암호화
  • 기본 권한 ROLE_USER 부여

9. UserController (API)

@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에 토큰 반환

인증 API 호출

Client → POST /api/posts (Header: Authorization: Bearer JWT)
→ JWTFilter (JWT 검증)
→ SecurityContext 설정
→ Controller

API 테스트

1. 회원가입

POST http://localhost:8080/api/signUp
Content-Type: application/json

{
  "username": "jinu",
  "password": "1234"
}

응답: 200 OK - 회원가입 성공


2. 로그인

POST http://localhost:8080/api/login
Content-Type: application/json

{
  "username": "jinu",
  "password": "1234"
}

응답:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

3. 인증 필요한 API

POST http://localhost:8080/api/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

{
  "title": "jwt 공부 중",
  "content": "재밌다"
}

핵심 포인트

1. JWT는 Stateless

// 서버는 토큰 저장 없이 검증만 수행
String token = jwtUtil.createJwt(username, role, expiredMs);

2. 비밀번호 암호화

// 회원가입 시
String encoded = bCryptPasswordEncoder.encode("1234");
// DB: $2a$10$N9qo8uLOickgx2Z...

// 로그인 시
boolean matches = bCryptPasswordEncoder.matches("1234", encoded);

3. 필터 순서

.addFilterBefore(new JWTFilter(jwtUtil), 
                UsernamePasswordAuthenticationFilter.class)

JWT 검증이 먼저 실행되어야 함

0개의 댓글