@Slf4j
@Component
@RequiredArgsConstructor
@Getter
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity-in-seconds}")
private long accessTokenValidityInSeconds;
@Value("${jwt.refresh-token-validity-in-seconds}")
private long refreshTokenValidityInSeconds;
// JWT 토큰 생성
public String createToken(Authentication authentication, long validitySeconds) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Date now = new Date();
Date validity = new Date(now.getTime() + validitySeconds * 1000);
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("userId", userDetails.getUserId())
.claim("auth", authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(",")))
.issuedAt(now)
.expiration(validity)
.signWith(getSigningKey())
.compact();
}
// AccessToken 생성
public String createAccessToken(Authentication authentication) {
return createToken(authentication, accessTokenValidityInSeconds);
}
// RefreshToken 생성
public String createRefreshToken(Authentication authentication) {
return createToken(authentication, refreshTokenValidityInSeconds);
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.error("유효하지 않은 JWT 토큰입니다.", e);
return false;
}
}
// 토큰에서 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims =Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
Long userId = claims.get("userId", Long.class);
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
CustomUserDetails principal = new CustomUserDetails(
userId,
claims.getSubject(),
"",
authorities.iterator().next().getAuthority().replace("ROLE_", ""),
true
);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
private SecretKey getSigningKey() {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
JWT(JSON Web Token)를 생성하고 검증하는 프로바이더 클래스다.
주요 메서드
createToken(Authentication authentication, long validitySeconds)
secretKey로 서명됨createAccessToken(Authentication authentication)
jwtProperties에서 설정된 액세스 토큰 유효 시간 사용createRefreshToken(Authentication authentication)
jwtProperties에서 설정된 리프레시 토큰 유효 시간 사용validateToken(String token)
JWT 파서를 사용하여 토큰의 서명과 구조를 확인getAuthentication(String token)
Authentication 객체) 추출getSigningKey()
JWT 서명에 사용되는 비밀키 생성jwtProperties에서 secretKey를 가져와 HMAC-SHA 알고리즘에 적합한 키로 변환주요 특징
CustomUserDetails를 사용하여 사용자 정보 관리Spring Security의 GrantedAuthority 객체로 변환@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
Authentication authentication = jwtProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다.", authentication.getName());
} else {
log.debug("유효한 JWT 토큰이 없습니다.");
}
filterChain.doFilter(request, response);
}
// Authroization 헤더에서 JWT 토큰을 추출
private String resolveToken(HttpServletRequest request) {
String BearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(BearerToken) && BearerToken.startsWith("Bearer ")) {
return BearerToken.substring(7);
}
return null;
}
}
Spring Security에서 사용되는 JWT(JSON Web Token) 인증 필터다. HTTP 요청에서 JWT 토큰을 추출하고 검증하여 사용자 인증을 처리한다.
doFilterInternal 메서드
이 메서드는 필터의 주요 로직을 담당
resolveToken 메서드를 호출하여 HTTP 요청 헤더에서 JWT 토큰을 추출
토큰이 존재하고 유효한지 확인
토큰이 유효하면:
jwtProvider.getAuthentication(jwt)를 통해 토큰에서 인증 정보를 추출SecurityContextHolder에 저장토큰이 없거나 유효하지 않으면:
filterChain.doFilter를 호출해 다음 필터로 진행
resolveToken 메서드
HTTP 요청에서 JWT 토큰을 추출하는 메서드
Authorization 헤더 값을 가져온다.
헤더 값이 존재하고 "Bearer "로 시작하면:
동작 과정
HTTP 요청이 도착하면 이 필터가 실행된다.이 필터는 JWT 기반 인증의 핵심 부분으로, 토큰의 유효성을 검증하고 인증 정보를 Spring Security의 컨텍스트에 설정하는 역할을 합니다.재시도Claude는 실수를 할 수 있습니다. 응답을 반드시 다시 확인해 주세요.
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) {
Authentication authentication = jwtProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다.", authentication.getName());
} else {
log.debug("유효한 JWT 토큰이 없습니다.");
}
filterChain.doFilter(request, response);
}
// Authroization 헤더에서 JWT 토큰을 추출
private String resolveToken(HttpServletRequest request) {
String BearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(BearerToken) && BearerToken.startsWith("Bearer ")) {
return BearerToken.substring(7);
}
return null;
}
}
동작 흐름
API 요청을 보낼 때 HTTP 헤더에 Authorization: Bearer [JWT토큰] 형식으로 토큰을 포함시킨다.@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException{
User user= userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다. " + email));
return new CustomUserDetails(
user.getId(),
user.getEmail(),
user.getPassword(),
user.getRole().name(),
user.isEnabled()
);
}
}
Spring Security의 인증 관리자(AuthenticationManager)가 이 서비스의 loadUserByUsername 메서드를 호출한다.CustomUserDetails 객체를 생성한다.Spring Security는 이 객체를 사용하여 입력된 비밀번호와 저장된 비밀번호를 비교하고, 인증을 처리한다.public class AuthDto {
public record SignupRequest(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
String password,
@NotBlank(message = "이름은 필수 입력값입니다.")
String name,
@Pattern(regexp = "^01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$", message = "휴대폰 번호 형식이 올바르지 않습니다.")
String phone
) {}
public record LoginRequest(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
String password
) {}
public record TokenResponse(
String accessToken,
String refreshToken,
String tokenType,
Long expiresIn
) {}
public record TokenRefreshRequest(
@NotBlank(message = "리프레시 토큰은 필수 입력값입니다.")
String refreshToken
) {}
}
인증 관련 데이터 전송 객체(DTO)들을 모아놓은 클래스다.
DTO를 한 클래스 안에 중첩해서 넣는 방식은 몇 가지 이유
관련성 명확화: 하나의 도메인이나 기능(예: 인증)과 관련된 모든 DTO를 하나의 클래스 아래 그룹화하여 관련성을 명확히 한다.
네임스페이스 관리: AuthDto.LoginRequest, AuthDto.SignupRequest와 같이 사용하면 DTO의 용도가 즉시 이해된다. 이는 특히 다양한 컨트롤러에서 비슷한 이름의 DTO를 사용할 때 유용하다다.
패키지 정리: 각 DTO를 별도 클래스로 만들면 패키지에 많은 파일이 생길 수 있어 코드 탐색이 복잡해질 수 있다.
관례적 사용: 특히 Java 기반 프로젝트에서는 이런 방식이 꽤 일반적인 패턴이다.
각 DTO를 별도 파일로 분리하는 것이 더 나은 경우
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtProvider jwtProvider;
@Transactional
public void signup(AuthDto.SignupRequest request) {
// 이메일 중복 확인
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
// 사용자 생성
User user = User.builder()
.email(request.email())
.password(passwordEncoder.encode(request.password()))
.name(request.name())
.phone(request.phone())
.role(UserRole.ROLE_USER)
.enabled(true)
.build();
userRepository.save(user);
}
@Transactional
public AuthDto.TokenResponse login(AuthDto.LoginRequest request) {
// 인증 시도
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
// 인증 성공 시 SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// JWT 토큰 생성
String accessToken = jwtProvider.createAccessToken(authentication);
String refreshToken = jwtProvider.createRefreshToken(authentication);
return AuthDto.TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(jwtProvider.getAccessTokenValidityInSeconds())
.build();
}
}
회원 가입과 로그인을 하기 위한 Service 클래스다.
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "인증", description = "인증 관련 API")
public class AuthController {
private final AuthService authService;
@Operation(summary = "회원가입", description = "이메일, 비밀번호, 이름, 전화번호를 입력받아 회원가입을 진행합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "409", description = "이미 사용 중인 이메일")
})
@PostMapping("/signup")
public ResponseEntity<String> signup(
@Parameter(description = "회원가입 정보", required = true)
@Valid @RequestBody AuthDto.SignupRequest request) {
log.info("회원가입 컨트롤러 호출: {}", request.email());
authService.signup(request);
return ResponseEntity.status(HttpStatus.CREATED).body("회원가입이 완료되었습니다.");
}
@Operation(summary = "로그인", description = "이메일과 비밀번호를 입력받아 로그인하고 JWT 토큰을 발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그인 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "400", description = "잘못된 요청")
})
@PostMapping("/login")
public ResponseEntity<AuthDto.TokenResponse> login(
@Parameter(description = "로그인 정보", required = true)
@Valid @RequestBody AuthDto.LoginRequest request) {
log.info("로그인 컨트롤러 호출: {}", request.email());
AuthDto.TokenResponse tokenResponse = authService.login(request);
return ResponseEntity.ok(tokenResponse);
}
}
@SpringBootApplication
@EnableJpaAuditing
public class FreeMarketApplication {
public static void main(String[] args) {
SpringApplication.run(FreeMarketApplication.class, args);
}
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
@Bean
public AuditorAware<Long> auditorProvider() {
return new AuditorAwareImpl();
}
}
Querydsl을 사용하기 위한 JPAQueryFactory 빈을 정의한다.
EntityManager를 주입받아 JPAQueryFactory를 생성합니다.
JPA Auditing에서 현재 사용자(감사자) 정보를 제공하는 AuditorAware 인터페이스의 구현체를 빈으로 등록한다.
AuditorAwareImpl은 현재 인증된 사용자의 ID를 반환하는 커스텀 구현체입니다.


성공적으로 테스트가 끝나서 이제 RefreshToken 관련 코드를 추가해준다.
entity
@Entity
@Table(name = "refresh_tokens")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private LocalDateTime expiryDate;
@Builder
public RefreshToken(String token, Long userId, LocalDateTime expiryDate) {
this.token = token;
this.userId = userId;
this.expiryDate = expiryDate;
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
public void updateToken(String token, LocalDateTime expiryDate) {
this.token = token;
this.expiryDate = expiryDate;
}
}
repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
Optional<RefreshToken> findByUserId(Long userId);
void deleteByUserId(Long userId);
}
만료된 토큰을 삭제하는 것은 JPA만으로는 할 수 없어 QueryDSL을 사용하여 쿼리를 만들어줄 것이다.
RefreshTokenRepositoryCustom
public interface RefreshTokenRepositoryCustom {
void deleteExpiredTokens(LocalDateTime now); // 만료된 토큰 삭제
}
먼저 위와 같이 커스텀 리포지토리를 생성해준 뒤에 이걸 구현할 클래스를 생성해주면 된다.
RefreshTokenRepositoryImpl
@RequiredArgsConstructor
public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public void deleteExpiredTokens(LocalDateTime now) {
queryFactory
.delete(refreshToken)
.where(refreshToken.expiryDate.lt(now))
.execute();
}
}
refreshToken은 QRefreshToken을 static import 해주었기 때문에 바로 사용할 수 있다.
마지막으로 RefreshTokenRepository가 커스텀 인터페이스를 상속하도록 수정해준다.
RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long>, RefreshTokenRepositoryCustom {
Optional<RefreshToken> findByToken(String token);
Optional<RefreshToken> findByUserId(Long userId);
void deleteByUserId(Long userId);
}
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProvider jwtProvider;
@Transactional
public void saveRefreshToken(String token, Long userId) {
LocalDateTime expiryDate = LocalDateTime.now()
.plusSeconds(jwtProvider.getRefreshTokenValidityInSeconds());
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
.map(existing -> {
// 기존 토큰이 있으면 업데이트
existing.updateToken(token, expiryDate);
return existing;
})
.orElse(RefreshToken.builder()
.token(token)
.userId(userId)
.expiryDate(expiryDate)
.build());
refreshTokenRepository.save(refreshToken);
log.info("RefreshToken 저장 완료: 사용자 ID {}", userId);
}
public RefreshToken validateRefreshToken(String token) {
RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
.orElseThrow(() -> {
log.warn("존재하지 않는 RefreshToken");
return new RuntimeException("유효하지 않은 토큰입니다.");
});
if (refreshToken.isExpired()) {
log.warn("만료된 리프레시 토큰: 사용자 ID {}", refreshToken.getUserId());
throw new RuntimeException("토큰이 만료되었습니다.");
}
return refreshToken;
}
@Transactional
public void deleteByUserId(Long userId) {
refreshTokenRepository.findByUserId(userId).ifPresent(token -> {
refreshTokenRepository.delete(token);
log.info("RefreshToken 삭제 완료: 사용자 ID {}", userId);
});
}
@Transactional
@Scheduled(cron = "0 0 */6 * * *") // 6시간마다 실행
public void cleanupExpiredTokens() {
log.info("만료된 리프레시 토큰 정리 시작");
refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now());
log.info("만료된 리프레시 토큰 정리 완료");
}
}
JWT 리프레시 토큰을 관리하는 서비스 클래스다. 리프레시 토큰은 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급받기 위해 사용된다.
AuthService
AuthService 안에 refreshToken 메서드를 추가해준다.
@Transactional
public AuthDto.TokenResponse refreshToken(AuthDto.TokenRefreshRequest request) {
log.info("토큰 갱신 요청");
// RefreshToken 검증
RefreshToken refreshToken = refreshTokenService.validateRefreshToken(request.refreshToken());
Long userId = refreshToken.getUserId();
// 사용자 정보 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("사용자 정보가 존재하지 않습니다."));
// 새로운 인증 객체 생성
CustomUserDetails userDetails = new CustomUserDetails(
user.getId(),
user.getEmail(),
user.getPassword(),
user.getRole().name(),
user.isEnabled()
);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// 새로운 토큰 생성
String newAccessToken = jwtProvider.createAccessToken(authentication);
String newRefreshToken = jwtProvider.createRefreshToken(authentication);
// RefreshToken 업데이트
refreshTokenService.saveRefreshToken(newRefreshToken, userId);
log.info("토큰 갱신 성공: 사용자 ID {}", userId);
return AuthDto.TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn(jwtProvider.getAccessTokenValidityInSeconds())
.build();
}
@Transactional
public void logout(Long userId) {
log.info("로그아웃 요청: 사용자 ID {}", userId);
refreshTokenService.deleteByUserId(userId);
log.info("로그아웃 성공: 사용자 ID {}", userId);
}
AuthService
RefreshToken 저장을 위해 login() 메서드를 수정해준다.
@Transactional
public AuthDto.TokenResponse login(AuthDto.LoginRequest request) {
try {
// 인증 시도
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
// 인증 성공 시 SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// JWT 토큰 생성
String accessToken = jwtProvider.createAccessToken(authentication);
String refreshToken = jwtProvider.createRefreshToken(authentication);
// 사용자 ID 추출
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
Long userId = userDetails.getUserId();
// 리프레시 토큰 저장
refreshTokenService.saveRefreshToken(refreshToken, userId);
log.info("로그인 성공: {}", request.email());
return AuthDto.TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(jwtProvider.getAccessTokenValidityInSeconds())
.build();
} catch (Exception e) {
log.warn("로그인 실패: {}, 원인: {}", request.email(), e.getMessage());
throw e;
}
}
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토큰 갱신 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 토큰")
})
@PostMapping("/refresh")
public ResponseEntity<AuthDto.TokenResponse> refreshToken(
@Parameter(description = "리프레시 토큰", required = true)
@Valid @RequestBody AuthDto.TokenRefreshRequest request) {
log.info("토큰 갱신 컨트롤러 호출");
AuthDto.TokenResponse tokenResponse = authService.refreshToken(request);
return ResponseEntity.ok(tokenResponse);
}
@Operation(summary = "로그아웃", description = "사용자의 인증 정보를 제거하고 리프레시 토큰을 무효화합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "401", description = "인증 필요")
})
@PostMapping("/logout")
public ResponseEntity<String> logout() {
log.info("로그아웃 컨트롤러 호출");
// SecurityContext에서 현재 인증된 사용자 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
authService.logout(userDetails.getUserId());
}
return ResponseEntity.ok("로그아웃 되었습니다.");
}
토큰 재발급과 로그아웃에 대한 엔드포인트를 추가해준다.

BaseException
@Getter
@RequiredArgsConstructor
public class BaseException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public BaseException(String message, HttpStatus status, String errorCode) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public BaseException(String message, HttpStatus status) {
this(message, status, status.name());
}
}
Spring 기반 애플리케이션에서 사용되는 기본 예외 클래스(BaseException)를 정의한다. 이 클래스는 다른 커스텀 예외들의 부모 클래스로 사용될 수 있다.
클래스 선언과 상속
BaseException은 Java의 기본 예외 클래스인 RuntimeException을 상속한다.RuntimeException은 확인되지 않은 예외(unchecked exception)로, 명시적인 예외 처리(try-catch)가 필요 없다.필드
HTTP 상태 코드를 나타내는 HttpStatus 열거형 값이다.사용 목적
AuthException
public class AuthException extends BaseException {
// 이메일 중복 예외
public static class EmailDuplicateException extends AuthException {
public EmailDuplicateException() {
super("이미 사용 중인 이메일입니다.", HttpStatus.CONFLICT, "AUTH_EMAIL_DUPLICATE");
}
}
// 인증 실패 예외
public static class AuthenticationFailedException extends AuthException {
public AuthenticationFailedException() {
super("인증에 실패했습니다.", HttpStatus.UNAUTHORIZED, "AUTH_FAILED");
}
}
// 리프레시 토큰 만료 예외
public static class RefreshTokenExpiredException extends AuthException {
public RefreshTokenExpiredException() {
super("리프레시 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED, "REFRESH_TOKEN_EXPIRED");
}
}
// 리프레시 토큰 없음 예외
public static class RefreshTokenNotFoundException extends AuthException {
public RefreshTokenNotFoundException() {
super("리프레시 토큰을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "REFRESH_TOKEN_NOT_FOUND");
}
}
public AuthException(String message, HttpStatus status, String errorCode) {
super(message, status, errorCode);
}
}
BaseException을 상속하는 인증 관련 예외의 기본 클래스이다.UserException
public class UserException extends BaseException {
public static class UserNotFoundException extends UserException {
public UserNotFoundException(String email) {
super("해당 이메일을 가진 사용자를 찾을 수 없습니다: " + email, HttpStatus.NOT_FOUND, "USER_NOT_FOUND");
}
public UserNotFoundException(Long id) {
super("해당 ID를 가진 사용자를 찾을 수 없습니다: " + id, HttpStatus.NOT_FOUND, "USER_NOT_FOUND");
}
}
// 비활성화된 사용자 예외
public static class UserDisabledException extends UserException {
public UserDisabledException() {
super("비활성화된 사용자입니다.", HttpStatus.FORBIDDEN, "USER_DISABLED");
}
}
public UserException(String message, HttpStatus status, String errorCode) {
super(message, status, errorCode);
}
}
이 코드는 사용자(User) 관련 예외들을 관리하는 클래스를 정의한다. BaseException을 상속받아 사용자 관련 예외의 기본 클래스인 UserException을 만들고, 그 안에 구체적인 사용자 예외 상황들을 내부 클래스로 정의하고 있다.
ApiResponse
@Getter
@Builder
public class ApiResponse<T> {
private final boolean success;
private final int status;
private final String message;
private final T data;
private final LocalDateTime timestamp;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.status(200)
.message("요청이 성공했습니다.")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> success(T data, String message) {
return ApiResponse.<T>builder()
.success(true)
.status(200)
.message(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> error(int status, String message) {
return ApiResponse.<T>builder()
.success(false)
.status(status)
.message(message)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> error(int status, String message, T data) {
return ApiResponse.<T>builder()
.success(false)
.status(status)
.message(message)
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
}
REST API 응답을 일관되게 포맷팅하기 위한 ApiResponse 클래스를 정의한다. 제네릭을 사용하여 다양한 타입의 데이터를 포함할 수 있는 유연한 응답 구조를 제공한다.
ErrorResponse
@Builder
public record ErrorResponse(
int status,
String message,
String errorCode,
LocalDateTime timestamp,
List<FieldError> errors
) {
@Builder
public ErrorResponse {
// Record의 컴팩트 생성자에서 기본값 설정
if (errors == null) {
errors = new ArrayList<>();
}
if (timestamp == null) {
timestamp = LocalDateTime.now();
}
}
@Builder
public record FieldError(
String field,
String value,
String reason
) {}
}
GlobalExceptionHandler
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// BaseException 처리
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleBaseException(BaseException e) {
log.error("BaseException: {}", e.getMessage(), e);
ErrorResponse errorResponse = new ErrorResponse(
e.getStatus().value(),
e.getMessage(),
e.getErrorCode(),
LocalDateTime.now(),
List.of()
);
return ResponseEntity.status(e.getStatus())
.body(ApiResponse.error(e.getStatus().value(), e.getMessage(), errorResponse));
}
// Spring Security 인증 예외 처리
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleAuthenticationException(AuthenticationException e) {
log.error("AuthenticationException: {}", e.getMessage(), e);
String message = "인증에 실패했습니다.";
if (e instanceof BadCredentialsException) {
message = "이메일 또는 비밀번호가 올바르지 않습니다.";
}
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.UNAUTHORIZED.value(),
message,
"AUTH_FAILED",
LocalDateTime.now(),
List.of()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(HttpStatus.UNAUTHORIZED.value(), message, errorResponse));
}
// 접근 거부 예외 처리
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleAccessDeniedException(AccessDeniedException e) {
log.error("AccessDeniedException: {}", e.getMessage(), e);
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.FORBIDDEN.value(),
"접근 권한이 없습니다.",
"ACCESS_DENIED",
LocalDateTime.now(),
List.of()
);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다.", errorResponse));
}
// 유효성 검증 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("MethodArgumentNotValidException: {}", e.getMessage(), e);
List<ErrorResponse.FieldError> fieldErrors = e.getFieldErrors().stream()
.map(fieldError -> new ErrorResponse.FieldError(
fieldError.getField(),
fieldError.getRejectedValue() != null ? fieldError.getRejectedValue().toString() : "",
fieldError.getDefaultMessage()
))
.toList();
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"입력값이 올바르지 않습니다.",
"INVALID_INPUT",
LocalDateTime.now(),
fieldErrors
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "입력값이 올바르지 않습니다.", errorResponse));
}
// 바인딩 예외 처리
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleBindException(BindException e) {
log.error("BindException: {}", e.getMessage(), e);
List<ErrorResponse.FieldError> fieldErrors = e.getBindingResult().getFieldErrors().stream()
.map(fieldError -> new ErrorResponse.FieldError(
fieldError.getField(),
fieldError.getRejectedValue() != null ? fieldError.getRejectedValue().toString() : "",
fieldError.getDefaultMessage()
))
.toList();
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"입력값이 올바르지 않습니다.",
"INVALID_INPUT",
LocalDateTime.now(),
fieldErrors
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "입력값이 올바르지 않습니다.", errorResponse));
}
// 그 외 모든 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<ErrorResponse>> handleException(Exception e) {
log.error("Unexpected Exception: {}", e.getMessage(), e);
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"서버 내부 오류가 발생했습니다.",
"INTERNAL_SERVER_ERROR",
LocalDateTime.now(),
List.of()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.", errorResponse));
}
}
Spring Boot 애플리케이션에서 전역적으로 예외를 처리하는 GlobalExceptionHandler 클래스다. @RestControllerAdvice 어노테이션을 사용하여 애플리케이션 전반에서 발생하는 다양한 예외를 일관된 형식으로 처리한다.
회원가입을 하지 않고 로그인 하는 경우 예시

현재 작성해둔 ApiResponse 클래스는 Swagger의 @ApiResoponse 어노테이션과 이름이 충돌하고 있어 사용하려면 패키지 주소를 전부 다 적어줘야 되는 문제가 있다. 그렇기 때문에 ApiResponse가 아닌 ResponseDTO로 이름을 변경해준다.
AuthController
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "인증", description = "인증 관련 API")
public class AuthController {
private final AuthService authService;
@Operation(summary = "회원가입", description = "이메일, 비밀번호, 이름, 전화번호를 입력받아 회원가입을 진행합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "409", description = "이미 사용 중인 이메일")
})
@PostMapping("/signup")
public ResponseEntity<ResponseDTO<Void>> signup(
@Parameter(description = "회원가입 정보", required = true)
@Valid @RequestBody AuthDto.SignupRequest request) {
log.info("회원가입 컨트롤러 호출: {}", request.email());
authService.signup(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ResponseDTO.success(null, "회원가입이 완료되었습니다."));
}
@Operation(summary = "로그인", description = "이메일과 비밀번호를 입력받아 로그인하고 JWT 토큰을 발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그인 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "400", description = "잘못된 요청")
})
@PostMapping("/login")
public ResponseEntity<ResponseDTO<AuthDto.TokenResponse>> login(
@Parameter(description = "로그인 정보", required = true)
@Valid @RequestBody AuthDto.LoginRequest request) {
log.info("로그인 컨트롤러 호출: {}", request.email());
AuthDto.TokenResponse tokenResponse = authService.login(request);
return ResponseEntity.ok(ResponseDTO.success(tokenResponse, "로그인에 성공했습니다."));
}
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토큰 갱신 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 토큰")
})
@PostMapping("/refresh")
public ResponseEntity<ResponseDTO<AuthDto.TokenResponse>> refreshToken(
@Parameter(description = "리프레시 토큰", required = true)
@Valid @RequestBody AuthDto.TokenRefreshRequest request) {
log.info("토큰 갱신 컨트롤러 호출");
AuthDto.TokenResponse tokenResponse = authService.refreshToken(request);
return ResponseEntity.ok(ResponseDTO.success(tokenResponse, "토큰이 갱신되었습니다."));
}
@Operation(summary = "로그아웃", description = "사용자의 인증 정보를 제거하고 리프레시 토큰을 무효화합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "401", description = "인증 필요")
})
@PostMapping("/logout")
public ResponseEntity<ResponseDTO<Void>> logout() {
log.info("로그아웃 컨트롤러 호출");
// SecurityContext에서 현재 인증된 사용자 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
authService.logout(userDetails.getUserId());
}
return ResponseEntity.ok(ResponseDTO.success(null, "로그아웃 되었습니다."));
}
}
회원가입

로그인
