2025년 2월 18일에 작성됨
지난 포스팅에서는 회원가입 로직 및 보안 설정에 관해 포스팅하였다.
이제는 JWT를 활용해서 로그인 및 API 인증 처리를 구현해야한다.
JWT란?
JWT는 로그인한 사용자를 인증하고 정보를 안전하게 주고받을 수 있는 토큰(Token) 방식이다.
일반적으로 로그인 시스템에서 세션 대신 사용되고, 무상태(Stateless) 인증 방식을 지원한다.
그렇다면 JWT를 사용하는 이유는 뭘까?
1. 세션 저장소 없이 사용자 인증 가능
- 기존 로그인 방식에서는 서버가 사용자 정보를 세션에 저장해야 했다.
- 하지만 JWT는 토큰 자체에 정보가 포함되므로 서버가 별도로 저장할 필요가 없다.
- 이는 분산 시스템에서도 사용하기 좋다.
2. 빠르고 효율적인 인증 방식
- 세션 방식은 요청할 때마다 DB나 캐시 서버에서 세션 정보를 조회해야 한다.
- 하지만 JWT는 토큰을 검사하는 것만으로 인증이 완료된다.
- 성능이 더 좋고 확장성이 뛰어나다.
3. 보안성
- JWT는 서명이 포함되어 있어 변조가 불가능하다.
- 비밀번호 같은 민감한 데이터 대신 인증된 정보만 담을 수 있다.
JWT는 세 부분으로 이루어져 있다.
- 헤더(Header)
- 페이로드(Payload)
- 서명(Signature)
그렇다면 JWT를 이 프로젝트의 어느 부분에 어떻게 적용시켜야할까?
JWT를 사용한 로그인 과정(인증 흐름)을 보겠다.
- 사용자가 로그인 요청을 보낸다. (POST /api/auth/login)
- 서버는 사용자를 검증 후 JWT를 발급해준다.
- 클라이언트는 API 요청 시 JWT를 포함해서 보낸다.
- 서버는 JWT의 서명을 검증하고, 사용자 정보를 확인한다.
- 검증이 완료되면 요청을 처리한다.
JWT 발급을 위한 JwtUtil 클래스 생성
우선 JWT를 발급하고 검증하는 JwtUtil 클래스를 만들어야 한다.
JwtUtil 클래스는 사용자의 로그인 정보를 기반으로 JWT를 생성하고, 요청이 들어올 때마다 유효성을 검증하는 역할을 한다.
package com.example.autofinder.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
@Component
public class JwtUtil {
private static final String SECRET_KEY = "your-key"; // 32바이트 이상 필요
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24; // JWT의 유효 기간
// SECRET_KEY를 이용해 HMAC-SHA 알고리즘으로 서명할 키 생성
private final Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
// JWT 생성 (역할 포함)
public String generateToken(String username, String role) {
return Jwts.builder()
.setSubject(username) // 사용자 정보 (subject) 설정
.claim("role", role) // 사용자 역할 (role) 추가
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간
.signWith(key, SignatureAlgorithm.HS256) // HMAC-SHA256 알고리즘으로 서명
.compact(); // JWT 문자열 반환
}
// 토큰 검증 후 사용자 이름 반환
public String validateToken(String token) {
try {
// JWT 파싱 및 검증
Claims claims = Jwts.parserBuilder()
.setSigningKey(key) // 서명 키 설정
.build()
.parseClaimsJws(token) // JWT를 파싱하여 Claims(페이로드) 추출
.getBody();
return claims.getSubject(); // 사용자 이름 반환
} catch (JwtException e) {
return null; // 검증 실패 시 null 반환
}
}
// 토큰에서 역할(Role) 추출
public String extractRole(String token) {
try {
// JWT 파싱 및 역할 정보 추출
Claims claims = Jwts.parserBuilder()
.setSigningKey(key) // 서명 키 설정
.build()
.parseClaimsJws(token) // JWT를 파싱하여 Claims(페이로드) 추출
.getBody();
return claims.get("role", String.class); // 역할(Role) 값 반환
} catch (JwtException e) {
return null; // 검증 실패 시 null 반환
}
}
}
이 코드의 기능을 설명하자면,
JWT 생성 - 사용자 정보(이름, 역할)을 포함한 JWT를 생성하고, 비밀키로 서명한다.
JWT 검증 - 그리고 클라이언트가 보낸 토큰을 검증하고, 정상적인 경우 사용자 이름을 반환한다.
JWT에서 역할 추출 - JWT에 저장된 역할 정보를 가져와서 반환한다.
정도의 역할을 한다.
이 코드의 실행 흐름은 사용자가 로그인하면 generateToken(username, role)
을 호출하여 JWT를 생성하고 반환한다. 이 JWT를 클라이언트(브라우저 또는 앱)에서 저장 후 API 요청 시 사용한다.
그리고 사용자가 API 요청을 보낼 때에는 HTTP 헤더에 "Authorization": "Bearer {JWT}"를 포함하여 요청하고, 서버에서 validateToken(token)을 호출하여 JWT의 유효성을 확인한다. 마지막으로 extractRole(token)
을 호출하여 사용자의 역할(Role)도 확인한다.
회원가입, 로그인 로직
그 다음 코드는 로그인 시 JWT를 발급해야하기 때문에 저번에 만든 API를 수정해야한다.
로그인 성공 시 사용자 이름, 역할(Role) 정보를 포함한 JWT를 생성해서 반환하게 된다.
package com.example.autofinder.controller;
import com.example.autofinder.model.User;
import com.example.autofinder.service.UserService;
import com.example.autofinder.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController // REST API 컨트롤러 역할을 하는 클래스
@RequestMapping("/api/auth") // "/api/auth" 경로의 API 요청을 처리
@RequiredArgsConstructor // 필수 의존성(필드)들을 자동으로 주입 (Lombok 기능)
public class AuthController {
private final UserService userService; // 사용자 관련 로직을 처리하는 서비스
private final JwtUtil jwtUtil; // JWT 생성 및 검증을 담당하는 유틸 클래스
// 회원가입 API
@PostMapping("/register")
public ResponseEntity<User> register(@RequestBody Map<String, String> request) {
String username = request.get("username"); // 요청 본문에서 사용자 이름 가져오기
String password = request.get("password"); // 요청 본문에서 비밀번호 가져오기
String role = request.getOrDefault("role", "USER"); // 기본값: USER
User newUser = userService.registerUser(username, password, role); // 회원가입 처리
return ResponseEntity.ok(newUser); // 등록된 사용자 정보 반환 (200 OK)
}
// 로그인 API (JWT 토큰 발급)
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody Map<String, String> request) {
String username = request.get("username"); // 요청 본문에서 사용자 이름 가져오기
String password = request.get("password"); // 요청 본문에서 비밀번호 가져오기
User user = userService.authenticateUser(username, password); // 사용자 인증 수행
if (user != null) {
// 인증 성공 시 JWT 토큰 생성 및 반환
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
return ResponseEntity.ok(token); // JWT 반환
} else {
// 인증 실패 시 401 Unauthorized 응답
return ResponseEntity.status(401).body("로그인 실패: 사용자명 또는 비밀번호가 잘못되었습니다.");
}
}
}
위 코드의 실행흐름을 살펴보자.
회원가입 (/api/auth/register)
- 클라이언트가 POST 방식으로
/api/auth/register
를 요청한다. - 서버가
UserService.registerUser
를 호출한다. - 서버가 사용자 정보를 데이터베이스에 저장한다.
- 서버가 저장에 성공하면 등록된 사용자 정보를 반환한다.
로그인 (/api/auth/login)
- 클라이언트가 POST 방식으로
/api/auth/login
를 요청한다. - 서버가
UserService.authenticateUser
를 호출한다. - 서버가 인증 성공 시 JWT 토큰을 생성해서 반환한다.
- 클라이언트는 받을 토큰을 이후 API 요청에서 Authorization 헤더에 포함해서 사용한다.
JWT 인증 로직
다음은 JwtAuthenticationFilter
클래스에 대한 설명이다.
package com.example.autofinder.security;
import com.example.autofinder.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
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 org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component // Spring이 관리하는 Bean으로 등록
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; // JWT 유틸리티 클래스
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 요청 헤더에서 "Authorization" 값 가져오기
String token = request.getHeader("Authorization");
// 토큰이 존재하고 "Bearer "로 시작하는지 확인
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); // "Bearer " 제거 후 실제 JWT 토큰 값 추출
String username = jwtUtil.validateToken(token); // JWT 검증 후 사용자 이름 가져오기
String role = jwtUtil.extractRole(token); // JWT에서 역할(role) 정보 추출
// 디버깅용 로그
System.out.println("JWT 필터 - 사용자: " + username + ", 역할: " + role);
// 토큰이 유효하면 SecurityContext에 인증 정보 저장
if (username != null && role != null) {
// Spring Security는 "ROLE_"을 자동으로 추가하므로, ROLE_ 붙여서 저장
List<SimpleGrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
// Spring Security의 UserDetails 객체 생성 (비밀번호는 필요 없으므로 빈 값 전달)
UserDetails userDetails = new User(username, "", authorities);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
// SecurityContextHolder에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
// 다음 필터로 요청 전달
chain.doFilter(request, response);
}
}
이번에는 doFilterInternal()
의 실행 흐름을 살펴보자.
- 클라이언트가 API 요청 시, Authorization 헤더에 JWT 토큰을 포함해서 보낸다.
- 필터가 Authorization 헤더에서 "Bearer "로 시작하는 JWT 토큰을 추출한다.
jwtUtil.validateToken(token)
을 통해 토큰의 유효성을 검증하고, 사용자 정보를 추출한다.jwtUtil.extractRole(token)
을 통해 사용자의 역할을 가져온다.- Spring Security에서 요구하는
UserDetails
객체를 생성하고, 인증 객체(SecurityContextHolder)를 저장한다. - 다음 필터로 요청을 넘겨준다. (JWT가 없거나 검증 실패 시 인증 없이 요청 처리된다.)
정리
Spring Security에서 JWT 인증을 적용하는 이유를 정리하자면 다음과 같다.
- 세션이 필요 없다. (JWT 자체에 사용자 정보를 포함하여 서버 부담이 줄어든다.)
- 확장성이 뛰어나다. (마이크로서비스 환경에서도 사용이 가능하다.)
- 보안이 강화된다. (JWT 서명을 통해 위변조를 방지한다.)
이번에 JWT라는 것을 처음 사용해보는데, 생각보다 많이 복잡하다고 느꼈다. 이유는 프로젝트를 진행하면서 ROLE_
이라는 문자열로 인해 데이터베이스에 데이터가 어떻게 저장되는지, 그리고 Spring Security
의 동작 방식 때문에 많은 어려움을 겪었다. 어느 부분에서 ROLE_
를 제거하고, 부족하면 추가해야하는지에 대한 문제였다.
결과적으로 현재는 ROLE_
이라는 텍스트를 JWT 생성시 추가하고, 역할을 추출할 때 ROLE_
을 제거해서 UserDetails
에 등록하는 로직이 완성되었다. 심지어 Spring Security
는 내부적으로 ROLE_
을 자동으로 추가해서 처리하기 때문에 더 헷갈렸다.
앞으로 프로젝트를 진행하면서 이와 같은 문제들이 많이 발생할 것 같은데, 그 때는 더 차분히 코드를 읽어나가면서 이해를 해봐야겠다.
'SIDE PROJECT > AUTOFINDER' 카테고리의 다른 글
프론트엔드 연동 (React, Tailwind 적용) (0) | 2025.04.17 |
---|---|
백엔드 코드 개선 및 쿼리문 수정 (0) | 2025.04.17 |
회원가입, 보안 로직 개발 (1) | 2025.04.17 |
CRUD 기능 구현 (0) | 2025.04.17 |
스프링 부트(Spring Boot)를 이용한 서버 구축 (0) | 2025.04.17 |