삶 가운데 남긴 기록 AACII.TISTORY.COM
JWT 인증 본문
서버 사이드 랜더링(SSR)은 브라우저로 필요한 화면(html)을 전송해주는데, 보통 jsp나 Thymeleaf 등을 사용합니다.
그런데 REST API 서버 처럼 JSON(혹은 XML)을 이용해서 데이터를 주고 받는 경우, 무상태에다가 화면을 제공하지 않기 때문에 HttpSession이나 쿠키로 인증하는 방법에 제한을 받습니다.
따라서 이런 제한을 피하기 위해 토큰을 이용해서 인증하는 방법을 JWT(JSON Web Token) 이라고 합니다.
- 인증(Authentication): 사용자의 아이디와 비밀번호가 올바른지 확인합니다.
- JWT 생성: 인증에 성공하면 사용자 정보를 담은 JWT를 생성합니다.
- JWT 전송: 생성된 JWT를 클라이언트에게 응답으로 보냅니다.
- 인가(Authorization)와 보호된 리소스 접근: 클라이언트는 이후 보호된 리소스(API)에 접근 할 때마다 JWT를 요청 헤더에 담아 전송합니다.
- 토큰 검증(Validation): 토큰이 유효한 경우에만 리소스 접근을 허용합니다.
토큰 기반 인증
API 서버는 호출 주소를 알면 누구나 호출 할 수 있기 때문에 다양한 방법으로 특정 사용자나 프로그램에서만 API 서버를 호출할 수 있도록 제한하는 방법이 필요합니다.
토큰 방식은 API 서버를 이용하는 사람들이 API 서버에서 토큰을 발행 받아 보관하다가 API를 호출할 때 자신이 가진 토큰을 같이 서버에 전달해서 API 서버에서 이 토큰을 확인하는 방법입니다.
이러한 토큰을 Access Token 이라고 합니다.
- 사용자는 서버로 부터 Access Token과 Refresh Token을 받습니다.
- 사용자가 API를 호출하기 위해 서버로 Access Token을 전달합니다.
- 서버는 Access Token을 검사해서 유효한지 확인해서 필요한 작업을 처리합니다.
만약, 이 토큰을 탈취당하면 문제가 발생합니다.
따라서 이 토큰의 유효 시간을 짧게 지정하고, 사용자에게 토큰을 새로 발급받을 수 있는 Refresh Token을 생성해줘서 필요할 때 다시 엑세스 토큰을 발급 받을 수 있게 합니다.
만약 Access Token이 만료되었다고 가정합시다.
- 사용자가 서버로 Access Token을 전달합니다.
- 서버는 Access Token을 검증하고 만료된 토큰임을 확인하고 사용자에게 만료된 토큰임을 알려줍니다.
- 사용자는 가지고 있던 Refresh Token을 전송해서 새 Access Token을 서버에 요청합니다.
- 서버는 Refresh Token에 문제가 없다면 새로운 Access Token을 생성해서 전달합니다. 이 때 Refresh Token이 거의 만료가 된 상황이면 새 Refresh Token을 같이 전송합니다.
만약 Access Token과 Refresh Token 모두 탈취 당하면 서버는 사용자를 구분할 수 없습니다.
이러면 해결 방법이 사실 없기 때문에, 사용자에게 알리거나 추가인증을 하는 방법을 사용합니다.
예를들어 구글이나 네이버 로그인을 평소와 다른 기기(장소)에서 접속하면 메일이나 휴대전화로 알림을 보내거나 추가 인증하는 방법을 사용합니다.
Refresh Token은 유효 기간을 길게 주는데 이역시 해당 기간동안 사용하지 않으면 만료됩니다.
그래서 Access Token을 생성 할 때 Refresh Token도 같이 생성해서 전달해주는 방식을 사용합니다.
JWT 로그인 로직 구현
JWT로 로그인 구현은 다음과 같은 단계로 진행됩니다.
1. 개발 환경 설정: 의존성 추가(Spring Security, JWT, Lombok 등)
2. JWT 컴포넌트 구현: JwtTokenProvider으로 JWT생성, 유효성검사, 토큰에서 사용자 정보추출 등
3. Spring Security 설정:SecurityConfig으로 Security 필터 체인 구성, 세션 비활성화, JWT필터 추가, 접근 권한 설정
4. 로그인 구현 및 테스트: AuthController으로 로그인 요청을 처리하고 JWT를 발급하는 컨트롤러 구현 및 테스트
1. 의존성 추가(Maven 기준)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version> </dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
2. JWT 관련 컴포넌트 구현
application.properties 기준 JWT 비밀키와 만료 시간을 설정합니다.
jwt.secret-key=aVerySecretKeyForJwtTokenGenerationAndValidationThatIsLongEnough
jwt.expiration-time=86400000 # 24시간 (밀리초 단위)
비밀키는 길고 복잡한 문자열로 설정해야 합니다.
JwtTokenProvider.java 클래스를 생성해서 JWT를 생성하고 복호화하고 유효성 검증을 합니다.
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
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.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
private final Key key;
private final long expirationTime;
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey,
@Value("${jwt.expiration-time}") long expirationTime) {
// Base64 디코딩하여 Key 생성
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.expirationTime = expirationTime;
}
// JWT 토큰 생성
public String generateToken(Authentication authentication) {
// 사용자 권한 정보 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date expiration = new Date(now + expirationTime);
// Access Token 생성
return Jwts.builder()
.setSubject(authentication.getName()) // 사용자 이름 (ID)
.claim("auth", authorities) // 권한 정보
.setExpiration(expiration) // 만료 시간
.signWith(key, SignatureAlgorithm.HS256) // 서명
.compact();
}
// JWT 토큰으로 인증 정보 조회
public Authentication getAuthentication(String token) {
// 토큰 복호화
Claims claims = parseClaims(token);
// 권한 정보 추출
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체 생성 (비밀번호는 null 또는 빈 문자열)
UserDetails principal = new User(claims.getSubject(), "", authorities);
// Authentication 객체 반환
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
// 잘못된 JWT 서명
} catch (ExpiredJwtException e) {
// 만료된 JWT 토큰
} catch (UnsupportedJwtException e) {
// 지원되지 않는 JWT 토큰
} catch (IllegalArgumentException e) {
// JWT 토큰이 잘못됨
}
return false;
}
// Claims 추출 (토큰 복호화)
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
// 만료된 토큰이어도 Claims는 가져와야 함 (refresh token 등의 로직을 위해)
return e.getClaims();
}
}
}
3. Spring Security 설정
JwtAuthenticationFilter.java 클래스로 모든 요청에 대해 JWT를 검사하고 Security Context에 인증 정보를 설정하는 필터 입니다.
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 javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
// HTTP 요청 헤더에서 JWT 토큰 추출
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. Request Header에서 토큰 추출
String token = resolveToken(request);
// 2. validateToken으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
그 다음 SecurityConfig.java 설정을 오버라이드 해서 JWT 기반 인증 방식을 사용하도록 적용합니다.
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.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 PasswordEncoder passwordEncoder() {
// 비밀번호 암호화를 위한 인코더
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // REST API이므로 기본 설정 비활성화
.csrf().disable() // REST API이므로 csrf 비활성화
// 세션을 사용하지 않기 때문에 STATELESS 설정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 이 경로들은 인증 없이 접근 허용
.antMatchers("/auth/login", "/auth/signup").permitAll()
// 나머지는 모두 인증 필요
.anyRequest().authenticated()
.and()
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 이전에 추가
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
4. 로그인 로직 구현
로그인 요청 DTO를 LoginRequest.java 으로 생성합니다.
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginRequest {
private String username;
private String password;
}
AuthController.java 으로 로그인 처리를 하고 JWT를 발급합니다.
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
// 실제로는 UserDetailsService와 Repository 등을 통해 사용자 정보를 검증해야 합니다.
@PostMapping("/login")
public String login(@RequestBody LoginRequest loginRequest) {
// 1. Username + Password를 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 메서드가 실행될 때 CustomUserDetailsService에서 loadUserByUsername 메서드가 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
String jwt = jwtTokenProvider.generateToken(authentication);
// 4. 토큰 반환
return jwt; // 클라이언트에게 토큰을 응답합니다.
}
// 테스트를 위한 보호된 리소스
@GetMapping("/protected")
public String protectedResource() {
return "Protected Data! (JWT verified)";
}
}
위 컨트롤러가 정상 작동하려면 UserDetailsService의 서비스 구현이 반드시 필요합니다.
이 서비스에서 ID를 기반으로 DB에서 사용자 정보를 가져와서 SpringSecurity의 UserDetails 객체로 변환하고, PasswordEncoder를 이용해서 비밀번호를 비교하는 로직이 authenticationManagerBuilder.getObject().authenticate() 내부에서 수행되어야 합니다.
테스트
- DB 및 사용자 정보 준비: DB에 사용자 정보를 저장하고, UserDetailsService를 구현합니다. loadUserByUsername 메서드에서 사용자 인증 정보를 가져오도록 나머지 서비스 로직들을 구현합니다.
- 로그인 테스트: REST API 클라이언트를 사용하여 /auth/login 엔드포인트에 username과 password를 JSON 형태로 전송하여 JWT를 받습니다.
- 요청: POST /로그인용URI (Body: {"username": "testuser", "password": "password"})
- 응답: JWT 문자열
- 보호된 리소스 테스트: 발급받은 JWT를 사용하여 접근이 제한된 URI에 접근 테스트를 합니다.
- 요청: GET /접근제한URI
- 헤더: Authorization: Bearer <발급받은 JWT>

'DEV&OPS > Java' 카테고리의 다른 글
| Spring MVC PostgreSQL CRUD 예제 (0) | 2026.07.16 |
|---|---|
| Spring MVC에서 Model과 ModelAndView 그리고 CRUD (0) | 2026.07.16 |
| 스프링 부트 웹 애플리케이션에서 카카오 로그인 처리 (0) | 2026.07.16 |
| JPA 연관 관계 (0) | 2026.07.11 |
| 인텔리제이 주석 템플릿 설정 (0) | 2026.07.06 |
