왜 필요한가부터 JWT 인증 구현까지 — 빠짐없이 담은 완전 가이드
보안을 직접 구현하면 실수가 너무 많다. Spring Security는 인증/인가에 필요한 것들을 프레임워크 레벨에서 제공한다.
인증(Authentication) — 이 사람이 누구인가? (로그인)
인가(Authorization) — 이 사람이 이걸 해도 되나? (권한 체크)
Spring Security는 Filter 기반으로 동작한다. 요청이 Controller에 도달하기 전에 여러 Filter를 거친다.
Filter → 서블릿 레벨. DispatcherServlet 앞에서 동작. Spring Security가 여기서 동작.
Interceptor → 스프링 MVC 레벨. DispatcherServlet 이후, Controller 전에 동작.
Security는 반드시 Filter에서 처리해야 Controller에 도달 전에 막을 수 있음.
// 클라이언트 요청 → 서블릿 컨테이너 // → DelegatingFilterProxy → FilterChainProxy // → SecurityFilterChain (여러 Filter들의 체인) // → DispatcherServlet → Controller // 개발자가 직접 만드는 커스텀 필터 위치 지정 예시 http.addFilterBefore( jwtFilter, UsernamePasswordAuthenticationFilter.class ); // UsernamePasswordAuthenticationFilter 앞에 JwtFilter 끼워넣기
// Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' // JWT 라이브러리 (jjwt) implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
spring-boot-starter-security를 추가하는 순간 모든 API가 자동으로 막힘.
기본 로그인 페이지가 자동 생성되고, user / 랜덤 비밀번호로만 접근 가능.
→ SecurityConfig를 만들어서 직접 설정해야 함.
Spring Security 3.x (Spring Boot 3.x) 기준. WebSecurityConfigurerAdapter는 deprecated 됐고 SecurityFilterChain Bean을 직접 등록하는 방식을 씀.
@Configuration @EnableWebSecurity // Spring Security 활성화 @RequiredArgsConstructor public class SecurityConfig { private final JwtFilter jwtFilter; private final CustomAuthenticationEntryPoint authEntryPoint; private final CustomAccessDeniedHandler accessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // REST API는 CSRF 비활성화 (쿠키/세션 안 쓰니까) .csrf(csrf -> csrf.disable()) // JWT 쓰면 세션 불필요 → STATELESS .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // URL별 권한 설정 .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() // 로그인/회원가입은 허용 .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자만 .anyRequest().authenticated() // 나머지는 로그인 필요 ) // 401/403 커스텀 처리 .exceptionHandling(ex -> ex .authenticationEntryPoint(authEntryPoint) // 401 .accessDeniedHandler(accessDeniedHandler)) // 403 // CORS 설정 .cors(Customizer::withDefaults) // JWT 필터 등록 — UsernamePasswordAuthenticationFilter 앞에 .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }
| 설정 | 의미 |
|---|---|
csrf.disable() | REST API + JWT 방식에서는 CSRF 토큰 불필요. 비활성화. |
SessionCreationPolicy.STATELESS | 서버가 세션 만들지 않음. JWT 방식이니 세션 필요 없음. |
permitAll() | 로그인 없이도 접근 가능 |
authenticated() | 로그인한 사람만 접근 가능 |
hasRole("ADMIN") | ADMIN 권한 있는 사람만 접근 가능 |
인증 = 회사 출입증 발급 ("당신이 직원임을 확인했습니다")
인가 = 출입증으로 들어갈 수 있는 구역 체크 ("당신은 연구소 구역은 못 들어갑니다")
| HTTP Basic | Form 로그인 (세션) | JWT ⭐ | |
|---|---|---|---|
| 방식 | 매 요청마다 ID/PW를 Base64로 인코딩해서 헤더에 넣어 보냄 | 로그인하면 서버가 세션 생성, 클라이언트는 세션ID 쿠키로 보냄 | 로그인하면 토큰 발급, 이후 요청마다 토큰을 헤더에 넣어 보냄 |
| 서버 상태 | Stateless (매번 인증) | Stateful (세션 저장) | Stateless (토큰으로 증명) |
| 서버 확장 | 가능 | 서버 여러 대면 세션 공유 필요 | 서버 여러 대여도 문제 없음 |
| 보안 | 매번 PW 노출 위험 | 세션 하이재킹 위험 | 서명으로 위변조 방지 |
| 모바일/앱 | 불편 | 쿠키 처리 복잡 | 헤더에 토큰만 넣으면 됨 |
| 실무 | 거의 안 씀 | 모놀리식 웹에서 씀 | REST API 표준 |
서버가 상태를 저장하지 않아도 됨 (Stateless). 서버 여러 대 운영해도 세션 동기화 불필요.
클라이언트가 토큰을 들고 다니며 매 요청마다 자신을 증명. REST 원칙과 완벽히 일치.
Spring Security가 사용자 정보를 다루는 방식. DB에서 사용자를 어떻게 가져오는지 직접 구현해야 한다.
// Spring Security는 User 객체를 직접 모름 // UserDetails 인터페이스를 구현한 객체만 이해함 @Getter public class CustomUserDetails implements UserDetails { private final User user; // 우리 User 엔티티를 감쌈 public CustomUserDetails(User user) { this.user = user; } // 권한 목록 반환 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())); // ROLE_USER, ROLE_ADMIN 형식이어야 함 } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getEmail(); } // 이메일을 username으로 씀 (아이디 역할) // 계정 상태 관련 — 일단 전부 true로 반환 @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } // 우리 User 엔티티 꺼내기 public User getUser() { return user; } public int getUserId() { return user.getId(); } }
@Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("없는 사용자: " + email)); return new CustomUserDetails(user); } } // Spring Security가 로그인 처리할 때 이 메서드를 자동 호출함 // 이메일로 DB에서 사용자 찾아서 UserDetails로 감싸서 반환 // 없으면 UsernameNotFoundException → 자동으로 401 처리
DB가 해킹당했을 때 비밀번호가 그대로 노출됨.
BCrypt 같은 단방향 해시 알고리즘으로 암호화해서 저장해야 함.
단방향이라 복호화 불가 → 로그인할 때 입력한 PW를 같은 방식으로 해시해서 비교.
// SecurityConfig에서 Bean 등록 (Part 1 ④에서 이미 등록) @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 회원가입할 때 — 비밀번호 암호화해서 저장 @Transactional public User 회원가입(UserCreateRequest request) { User user = new User(); user.setPassword(passwordEncoder.encode(request.getPassword())); // "1234" → "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" return userRepository.save(user); } // 로그인할 때 — Spring Security가 자동으로 비교 // passwordEncoder.matches("입력한PW", "DB에저장된해시값") // → true / false // 직접 호출할 일은 거의 없음. AuthenticationManager가 자동으로 처리.
인증된 사용자 정보가 어디에 저장되는지, 그리고 코드에서 현재 로그인한 사용자를 어떻게 꺼내는지.
// 방법 1 — SecurityContextHolder에서 직접 꺼내기 public User getCurrentUser() { Authentication auth = SecurityContextHolder .getContext() .getAuthentication(); CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal(); return userDetails.getUser(); } // 방법 2 — Controller에서 @AuthenticationPrincipal 사용 (더 편리) // → ⑩에서 설명 // JwtFilter에서 인증 정보 저장하는 방법 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, // principal null, // credentials (JWT 방식에선 null) userDetails.getAuthorities() // 권한 목록 ); SecurityContextHolder.getContext().setAuthentication(authentication); // 이걸 설정해야 이후 Filter/Controller에서 인증된 사용자로 인식됨
SecurityContextHolder는 기본적으로 ThreadLocal에 저장함.
ThreadLocal = 각 스레드마다 독립적인 저장공간.
A 사용자의 요청 스레드와 B 사용자의 요청 스레드는 서로 다른 SecurityContext를 가짐.
→ 다른 사용자의 인증 정보가 섞이지 않음.
JWT가 뭔지, 어떻게 생겼는지, 전체 인증 흐름이 어떻게 되는지.
Payload는 누구나 디코딩해서 내용을 볼 수 있음.
비밀번호 같은 민감한 정보를 Payload에 넣으면 안 됨.
Signature로 위변조를 막는 것이지, 내용을 숨기는 게 아님.
| Access Token | Refresh Token | |
|---|---|---|
| 용도 | API 요청할 때마다 사용 | Access Token 만료됐을 때 재발급 요청 |
| 유효 기간 | 짧게 (30분 ~ 1시간) | 길게 (7일 ~ 30일) |
| 저장 위치 | 클라이언트 메모리 | HttpOnly Cookie 또는 DB |
| 이유 | 탈취당해도 짧게 만료 | 매번 로그인 안 해도 되도록 |