이전 포스팅에서 JWT에 대해 언급했으니
이제 이걸 프로젝트에 적용한 내용에 대해 작성하고자 한다.
jwt 라이브러리에 여러 종류가 있으나, 주로 JJWT와 Auth0이 사용된다.
JJWT vs Auth0 비교
| 비교 항목 | JJWT (io.jsonwebtoken) | Auth0 (com.auth0.jwt) |
|---|---|---|
| 개발사 | Stormpath (오픈소스, 현 jwtk) | Auth0 |
| 서명 알고리즘 | HS256, HS384, HS512, RS256 등 다양 | 동일 |
| JSON 파싱 | Jackson 또는 Gson (추가 모듈 필요) | 내장 |
| 주요 장점 | 직관적이고 Spring에 많이 쓰임 | 가볍고 체이닝 문법 깔끔 |
| 주요 단점 | 0.12.x 이전 버전에서는 Java 9+ 호환성 이슈 | Claims 타입이 단순 Map 형태 |
| 실무 사용 비율 | Spring Boot 프로젝트에서 압도적 | 경량 앱, Kotlin, Android에서 선호 |
어떤 걸 선택해야 할까?
| 상황 | 추천 |
|---|---|
| Spring Boot 백엔드 + JPA + Rest API | ✅ JJWT (io.jsonwebtoken) |
| Kotlin/Android, 가벼운 인증 모듈 | 🔹 Auth0 |
| RSA 비대칭키 기반 인증 필요 | JJWT(0.12.x 이상) 또는 Auth0 둘 다 OK |
FinTrack처럼 Spring Boot 기반 백엔드 프로젝트에서는
JJWT가 훨씬 자연스럽고, 시큐리티 필터와 통합도 쉽기 때문에 JJWT를 선택했다.
build.gradle에 다음과 같이 의존성을 주입한다.
// 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' // JSON 직렬화용
Jwts.builder()를 사용하여 JwtBuilder instance를 만든다.SecretKey 또는 비대칭 PrivateKey를 지정한다.compact() 메서드를 호출하여 설정된 signed jwt를 생성한다.public String generateAccessToken(String email){
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public String generateRefreshToekn(String email){
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
jwt를 만들고 parsing까지 완료했으니 jwt를 검증하고자 한다.
jjwt를 이용하면 아래의 프로세스로 검증이 가능하다.
Jwts.parserBuilder() 메서드를 사용해서 JwtParserBuilder 인스턴스를 만든다.build() 를 호출하면 thread-safe JwtParser가 반환된다.parseClaimsJws(jwtString) 메서드를 호출하면 오리지널 signed JWT가 반환된다.// 토큰 유효성 검증
public boolean validateToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch(ExpiredJwtException e){
System.out.println("JWT 만료됨");
} catch (MalformedJwtException e){
System.out.println("JWT 형식이 올바르지 않음");
} catch (SignatureException e){
System.out.println("JWT 서명 검증 실패");
} catch (Exception e) {
System.out.println("JWT 검증 중 예외 발생");
}
return false;
}
JwtProvider@Component
public class JwtProvider {
@Value("${JWT_SECRET}")
private String secretKey;
private Key key;
// Access / Refresh 토큰 만료 시간 -> 추후 서비스 특성 고려하여 수정하기
private final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 30; // 30분
private final long REFRESH_TOKEN_VALIDITY = 1000L * 60 * 60 * 24 * 7; // 7일
@PostConstruct
protected void init(){
byte [] keyBytes = Base64.getDecoder().decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Access Token 생성
public String generateAccessToken(String email){
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
// Refresh Token 생성
public String generateRefreshToekn(String email){
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
// 이메일 추출
public String getEmailFromToken(String token){
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 토큰 유효성 검증
public boolean validateToken(String token){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch(ExpiredJwtException e){
System.out.println("JWT 만료됨");
} catch (MalformedJwtException e){
System.out.println("JWT 형식이 올바르지 않음");
} catch (SignatureException e){
System.out.println("JWT 서명 검증 실패");
} catch (Exception e) {
System.out.println("JWT 검증 중 예외 발생");
}
return false;
}
}
@PostConstruct란?@PostConstruct는 의존성 주입이 이루어진 후 초기화를 수행하는 메서드이다. @PostConstruct가 붙은 메서드는 클래스가 service를 수행하기 전에 발생한다.
secretKey를 Base64 디코딩하여 Key 객체로 변환
@PostConstruct
protected void init(){
byte [] keyBytes = Base64.getDecoder().decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
LoginRequest@Getter @Setter
public class LoginRequest {
@Email(message = "유효한 이메일 형식이어야 합니다.")
@NotBlank(message = "이메일은 필수 입력값입니다.")
private String email;
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
private String password;
}
LoginResponse@Getter
@AllArgsConstructor
public class LoginResponse {
private String name;
private String email;
private String accessToken;
private String refreshToken;
}
AuthService@Service
@AllArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public LoginResponse login (LoginRequest request){
// 이메일 확인
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 이메일입니다."));
// 비밀번호 일치 확인
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())){
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
// JWT 발급
String accessToken = jwtProvider.generateAccessToken(user.getEmail());
String refreshToken = jwtProvider.generateRefreshToken(user.getEmail());
// 응답 반환
return new LoginResponse(user.getName(), user.getEmail(), accessToken, refreshToken);
}
}
AuthController@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
// 로그인
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request){
LoginResponse response = authService.login(request);
return ResponseEntity.ok(ApiResponse.success("로그인 성공", response));
}
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(){
// 클라이언트 측에서 토큰 삭제
return ResponseEntity.ok(ApiResponse.success("로그아웃 완료", null));
}
}