안녕하세요! 오늘은 Spring Boot 3.x 환경에서 JWT(JSON Web Token)를 이용한 인증 시스템을 처음부터 끝까지 구현해보겠습니다.
이 글에서는 이론보다는 실제 동작하는 코드에 집중하여, 여러분이 바로 프로젝트에 적용할 수 있도록 구성했습니다.
🎯 구현 목표
- Access Token과 Refresh Token을 활용한 JWT 인증
- Spring Security 6.x 기반 보안 설정
- 로그인, 토큰 갱신 API 구현
- 실무에서 바로 사용 가능한 완전한 코드
📦 1. 의존성 추가
먼저 build.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-data-jpa'
// 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'
// Database
runtimeOnly 'com.h2database:h2'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
⚙️ 2. application.yml 설정
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
show-sql: true
jwt:
secret: your-256-bit-secret-key-change-this-in-production-environment-must-be-long-enough
access-token-validity: 3600000 # 1시간 (밀리초)
refresh-token-validity: 604800000 # 7일 (밀리초)
⚠️ 주의: jwt.secret은 반드시 256비트(32바이트) 이상이어야 하며, 실제 운영 환경에서는 환경 변수로 관리해야 합니다.
👤 3. User 엔티티
package com.example.jwt.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
}
🔐 4. JWT 토큰 생성 및 검증 (핵심 클래스)
package com.example.jwt.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenValidity;
private final long refreshTokenValidity;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long accessTokenValidity,
@Value("${jwt.refresh-token-validity}") long refreshTokenValidity) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidity = accessTokenValidity;
this.refreshTokenValidity = refreshTokenValidity;
}
// Access Token 생성
public String createAccessToken(String username, Collection<? extends GrantedAuthority> authorities) {
String roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date now = new Date();
Date validity = new Date(now.getTime() + accessTokenValidity);
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(now)
.expiration(validity)
.signWith(secretKey)
.compact();
}
// Refresh Token 생성
public String createRefreshToken(String username) {
Date now = new Date();
Date validity = new Date(now.getTime() + refreshTokenValidity);
return Jwts.builder()
.subject(username)
.issuedAt(now)
.expiration(validity)
.signWith(secretKey)
.compact();
}
// 토큰에서 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("roles").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// 토큰에서 사용자명 추출
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
// 토큰 유효성 검증
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// 토큰 파싱
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
🔒 5. JWT 인증 필터
package com.example.jwt.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. Request Header에서 JWT 토큰 추출
String token = resolveToken(request);
// 2. 토큰 유효성 검사
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
// 3. 토큰에서 인증 정보 추출
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 4. SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
🛡️ 6. Security 설정
package com.example.jwt.config;
import com.example.jwt.security.JwtAuthenticationFilter;
import com.example.jwt.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 (JWT 사용 시)
.csrf(AbstractHttpConfigurer::disable)
// 세션 사용하지 않음
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 요청에 대한 인증 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 인증 API는 모두 허용
.requestMatchers("/api/admin/**").hasRole("ADMIN") // ADMIN만 접근 가능
.anyRequest().authenticated() // 나머지는 인증 필요
)
// JWT 인증 필터 추가
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
🔑 7. UserDetailsService 구현
package com.example.jwt.security;
import com.example.jwt.entity.User;
import com.example.jwt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@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("User not found: " + username));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()))
.build();
}
}
📝 8. Repository
package com.example.jwt.repository;
import com.example.jwt.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}
🎮 9. 인증 API 컨트롤러
package com.example.jwt.controller;
import com.example.jwt.dto.LoginRequest;
import com.example.jwt.dto.SignupRequest;
import com.example.jwt.dto.TokenResponse;
import com.example.jwt.entity.User;
import com.example.jwt.repository.UserRepository;
import com.example.jwt.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
// 회원가입
@PostMapping("/signup")
public ResponseEntity<?> signup(@RequestBody SignupRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
return ResponseEntity.badRequest().body("Username already exists");
}
User user = User.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.roles(List.of("ROLE_USER"))
.build();
userRepository.save(user);
return ResponseEntity.ok("User registered successfully");
}
// 로그인
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new BadCredentialsException("Invalid username or password"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new BadCredentialsException("Invalid username or password");
}
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
String accessToken = jwtTokenProvider.createAccessToken(user.getUsername(), authorities);
String refreshToken = jwtTokenProvider.createRefreshToken(user.getUsername());
return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
}
// 토큰 갱신
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@RequestHeader("Authorization") String refreshToken) {
String token = refreshToken.substring(7); // "Bearer " 제거
if (!jwtTokenProvider.validateToken(token)) {
throw new BadCredentialsException("Invalid refresh token");
}
String username = jwtTokenProvider.getUsername(token);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new BadCredentialsException("User not found"));
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
String newAccessToken = jwtTokenProvider.createAccessToken(username, authorities);
String newRefreshToken = jwtTokenProvider.createRefreshToken(username);
return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
}
}
📊 10. DTO 클래스들
package com.example.jwt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String username;
private String password;
}
package com.example.jwt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequest {
private String username;
private String password;
}
package com.example.jwt.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponse {
private String accessToken;
private String refreshToken;
}
🧪 11. 테스트용 컨트롤러
package com.example.jwt.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class TestController {
@GetMapping("/user")
public String user(Authentication authentication) {
return "Hello " + authentication.getName() + "! (USER)";
}
@GetMapping("/admin")
public String admin(Authentication authentication) {
return "Hello " + authentication.getName() + "! (ADMIN)";
}
}
🚀 12. 실행 및 테스트
1) 회원가입
curl -X POST http://localhost:8080/api/auth/signup \
-H "Content-Type: application/json" \
-d '{
"username": "user1",
"password": "password123"
}'
2) 로그인
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "user1",
"password": "password123"
}'
응답 예시:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
3) 인증이 필요한 API 호출
curl -X GET http://localhost:8080/api/user \
-H "Authorization: Bearer {accessToken}"
4) 토큰 갱신
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Authorization: Bearer {refreshToken}"
⚠️ 13. 주의사항 및 보안 팁
Secret Key 관리
- 절대 코드에 하드코딩하지 말 것
- 환경 변수나 AWS Secrets Manager 등 활용
- 256비트 이상의 충분히 긴 키 사용
토큰 저장
- Access Token: 메모리나 상태 관리 라이브러리에 저장
- Refresh Token: HttpOnly Cookie 사용 권장 (XSS 공격 방지)
HTTPS 필수
- 운영 환경에서는 반드시 HTTPS 사용
- 토큰이 평문으로 전송되므로 중간자 공격 위험
토큰 만료 시간
- Access Token: 짧게 (15분~1시간)
- Refresh Token: 길게 (7일~30일)
📌 14. 마무리
이번 글에서는 Spring Boot 3.x와 Spring Security 6.x를 사용하여 JWT 인증 시스템을 구현해보았습니다.
핵심 포인트:
- JWT 토큰 생성 및 검증 로직
- Spring Security Filter Chain 커스터마이징
- Access Token과 Refresh Token 분리 전략
- 실무에서 바로 사용 가능한 완전한 코드
질문이나 피드백은 댓글로 남겨주세요! 😊
'Spring Boot' 카테고리의 다른 글
| SockJS란? 실시간 통신을 위한 WebSocket 대안 (0) | 2025.12.11 |
|---|---|
| Spring RestTemplate vs WebClient 가이드 (0) | 2025.12.08 |
| Spring에서 Caffeine 캐시 완벽 가이드 (0) | 2025.12.05 |
| Spring Tag 라이브러리로 JSP include시 id 겹침 방지 (0) | 2025.01.03 |
| 개발 환경에서 서버 2개이상 돌릴 때 세션 유지 방법 (0) | 2023.12.19 |
댓글