들어가며
최근 프로젝트에서 Spring Security와 JWT를 활용한 인증 시스템을 구현하면서, 많은 고민과 시행착오를 겪었습니다. 인증 시스템은 프로젝트마다 요구사항이 다르고, 구현 방식도 천차만별인데요. 이번 글에서는 제가 설계한 인증 시스템의 전체 동작 과정을 단계별로 소개하고, 각 컴포넌트의 역할과 설계 의도를 공유해보려고 합니다.
전체 아키텍처 개요
구현한 인증 시스템은 다음과 같은 구조로 되어 있습니다:
- JWT 기반 토큰 인증
- Refresh Token을 통한 토큰 갱신
- 토큰 블랙리스트를 통한 로그아웃 처리
- Spring Security Filter Chain 활용
동작 과정 상세 분석
1. JWT 토큰 생성 (로그인 시)
회원가입이 완료된 상태에서 로그인이 진행되면, JwtTokenProvider의 createToken 메서드가 실행됩니다.
public String createToken(Long userId, String email, UserRole userRole) {
Date date = new Date();
String jti = UUID.randomUUID().toString(); // 토큰 일련번호
return Jwts.builder()
.id(jti) // JWT ID (토큰 식별용)
.subject(String.valueOf(userId)) // 사용자 ID
.claim("email", email) // 이메일 정보
.claim("userRole", userRole.name()) // 권한 정보
.issuer(jwtProperties.getIssuer())
.expiration(new Date(date.getTime() + jwtProperties.getExpirationTime()))
.issuedAt(date)
.signWith(key)
.compact();
}
여기서 주목할 점은 jti라는 값입니다. 이는 JWT ID의 약자로, 토큰의 일련번호 같은 역할을 합니다. UUID를 통해 랜덤한 값이 부여되며, 후에 토큰 블랙리스트 처리에 활용됩니다.
2. 토큰 유효성 검증
생성된 토큰의 유효성을 검증하는 parseToken 메서드입니다:
public Claims parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
if (!jwtProperties.getIssuer().equals(claims.getIssuer())) {
throw new BadCredentialsException("유효하지 않은 토큰입니다.");
}
return claims;
} catch (ExpiredJwtException e) {
throw new CredentialsExpiredException("토큰이 만료되었습니다.");
} catch (Exception e) {
throw new BadCredentialsException("유효하지 않은 토큰입니다.");
}
}
3. 인증된 사용자 정보 주입
인증이 필요한 엔드포인트에서는 다음과 같이 사용자 정보를 주입받습니다:
@GetMapping("/users/me")
public ResponseEntity<ApiResponse<UserInfoResponseDto>> getUserInfo(
@AuthenticationPrincipal UserPrincipal userPrincipal
) {
UserInfoResponseDto response = userService.getUserInfo(userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(response, "사용자 정보를 조회했습니다."));
}
@AuthenticationPrincipal 어노테이션은 현재 인증된 사용자의 정보를 컨트롤러 메서드의 파라미터로 자동 주입해주는 Spring Security의 기능입니다.
4. UserPrincipal과 UserDetails
UserPrincipal은 UserDetails 인터페이스를 구현한 클래스입니다. Spring Security 공식 문서에 따르면:
구현체들은 Spring Security가 보안 목적으로 직접 사용하지 않습니다. 단순히 사용자 정보를 저장하고, 나중에 Authentication 객체에 캡슐화됩니다. 이를 통해 보안과 관련없는 사용자 정보도 편리한 위치에 저장할 수 있습니다.
즉, UserDetails는 사용자 정보 컨테이너 역할을 하며, Authentication 객체에 감싸져서 사용됩니다.
@Getter
@Builder
public class UserPrincipal implements UserDetails {
private final Long id;
private final String username;
private final String email;
private final UserRole role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + role.name())
);
}
// ... 기타 UserDetails 메서드 구현
}
5. JWT 인증 프로바이더
JwtAuthenticationProvider는 실제 JWT 토큰을 검증하고 인증 객체를 생성하는 핵심 로직을 담당합니다:
private Authentication createAuthentication(String token) {
Claims claims = jwtTokenProvider.parseToken(token);
// 블랙리스트 검증
String jti = claims.getId();
if (StringUtils.hasText(jti) && tokenBlacklistService.isBlacklisted(jti)) {
throw new BadCredentialsException("로그아웃된 토큰입니다.");
}
// 사용자 정보 추출
String userIdStr = claims.getSubject();
String email = claims.get("email", String.class);
String roleStr = claims.get("userRole", String.class);
// UserPrincipal 생성
UserPrincipal userPrincipal = UserPrincipal.builder()
.id(Long.parseLong(userIdStr))
.email(email)
.role(UserRole.of(roleStr))
.build();
// 인증된 토큰 반환
return new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
userPrincipal.getAuthorities()
);
}
여기서 중요한 부분은 UsernamePasswordAuthenticationToken의 3개 파라미터 생성자를 사용한다는 점입니다. Spring Security 문서에 따르면:
이 생성자는 AuthenticationManager나 AuthenticationProvider만 사용해야 합니다. 신뢰할 수 있는 인증 토큰을 생성하는 것에 만족하는 경우에만 사용하며, 생성 즉시 인증된 상태가 됩니다.
즉, JWT 검증을 마친 후에만 이 생성자를 사용하여 "이미 검증된 신뢰할 수 있는 토큰"임을 명시합니다.
6. JWT 인증 필터
마지막으로 JwtAuthenticationFilter는 모든 요청에 대해 JWT 토큰 검증을 수행합니다:
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
Authentication authentication = jwtAuthenticationProvider.getAuthentication(request);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
이 필터는 OncePerRequestFilter를 상속받아 요청당 한 번만 실행되도록 보장합니다. 핵심 장점은:
- 요청당 한 번만 실행
- HTTP 타입 직접 사용
- 중복 실행 방지
설계 시 고려사항
1. 책임 분리
각 클래스가 명확한 책임을 가지도록 설계했습니다:
- JwtTokenProvider: JWT 생성/파싱만 담당
- JwtAuthenticationProvider: 인증 로직만 담당
- JwtAuthenticationFilter: 필터 로직만 담당
- TokenBlacklistService: 토큰 무효화만 담당
2. 확장성 고려
- 토큰 방식 변경 시 Provider만 수정하면 됨
- 블랙리스트 로직 추가 시 Service만 수정하면 됨
- 새로운 인증 방식 추가 시 기존 코드에 영향 없음
3. 가독성 향상
메서드명과 클래스명을 최대한 직관적으로 작성하여, 누구나 쉽게 이해할 수 있도록 노력했습니다.
마무리
인증 시스템은 프로젝트의 보안을 담당하는 핵심 컴포넌트입니다. 이것 외에도, 이번에 구현한 시스템은 JWT의 장점을 활용하면서도 보안성을 높이기 위해 블랙리스트 기능, 리프레시 토큰 등의 기능을 추가했고, Spring Security의 기본 구조를 최대한 활용하여 확장성과 유지보수성을 고려했습니다.
물론 더 좋은 구조나 개선점이 있을 수 있습니다. 인증 시스템은 정답이 없는 영역이기 때문에, 프로젝트의 요구사항과 팀의 상황에 맞는 최적의 해법을 찾아가는 것이 중요하다고 생각합니다.
이 글이 JWT 기반 인증 시스템을 구현하시는 분들께 조금이나마 도움이 되었으면 좋겠습니다.
'Spring 7기 프로젝트 > 아웃소싱 팀 프로젝트' 카테고리의 다른 글
| 내가 프로젝트에서 사용한 Java & Spring 문법 총 정리 및 개념 재정립 (1) | 2025.06.18 |
|---|---|
| [트러블슈팅] JWT + Spring Security 기반 인증/인가 시스템 구현 후기 (프로젝트 정리본) (0) | 2025.06.18 |
| [트러블슈팅] Spring Boot에서 이벤트 기반 아키텍처 구현 시 마주하는 도전과제와 해결책 (0) | 2025.06.16 |
| [트러블슈팅] 과도한 엔지니어링 : 이벤트 기반 아키텍처 삽질기 (0) | 2025.06.15 |
| [트러블슈팅] 대시보드 성능 최적화: GROUP BY 지옥에서 이벤트 기반 통계로 (0) | 2025.06.15 |