JWT 로그인 및 회원가입 구현 및 API 응답 형식 표준화

뚜우웅이·2025년 4월 4일

캡스톤 디자인

목록 보기
5/35

JWT 설정

JwtProvider

@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)

    • 기본 JWT 토큰을 생성하는 메서드
    • 토큰은 secretKey로 서명됨
  • createAccessToken(Authentication authentication)

    • 액세스 토큰 생성 (짧은 유효기간의 토큰)
    • jwtProperties에서 설정된 액세스 토큰 유효 시간 사용
  • createRefreshToken(Authentication authentication)

    • 리프레시 토큰 생성 (긴 유효기간의 토큰)
    • jwtProperties에서 설정된 리프레시 토큰 유효 시간 사용
  • validateToken(String token)

    • 토큰의 유효성 검증
    • JWT 파서를 사용하여 토큰의 서명과 구조를 확인
  • getAuthentication(String token)

    • 토큰에서 인증 정보(Authentication 객체) 추출
    • 토큰의 페이로드(클레임)에서 사용자 정보와 권한 정보를 추출
  • getSigningKey()

    • JWT 서명에 사용되는 비밀키 생성
    • jwtProperties에서 secretKey를 가져와 HMAC-SHA 알고리즘에 적합한 키로 변환

주요 특징

  • CustomUserDetails를 사용하여 사용자 정보 관리
  • 토큰에 사용자 ID와 권한 정보를 클레임으로 포함
  • 액세스 토큰과 리프레시 토큰을 분리하여 관리
  • 토큰 검증 실패 시 로깅 처리
  • 토큰에서 추출한 권한 정보를 Spring SecurityGrantedAuthority 객체로 변환

JwtFilter

@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 "로 시작하면:

      • "Bearer " 부분을 제거한 실제 토큰을 반환

동작 과정

  • 클라이언트가 보낸 HTTP 요청이 도착하면 이 필터가 실행된다.
  • 요청 헤더에서 JWT 토큰을 추출
  • 토큰이 유효하면 해당 토큰에서 사용자 정보를 추출하여 현재 요청 컨텍스트에 인증 정보를 설정한다.
  • 이후 다른 필터들과 컨트롤러에서는 이 인증 정보를 사용하여 사용자의 권한을 확인할 수 있다.

이 필터는 JWT 기반 인증의 핵심 부분으로, 토큰의 유효성을 검증하고 인증 정보를 Spring Security의 컨텍스트에 설정하는 역할을 합니다.재시도Claude는 실수를 할 수 있습니다. 응답을 반드시 다시 확인해 주세요.

Security

WebSecurityConfig 수정

@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토큰] 형식으로 토큰을 포함시킨다.
  • 이 필터는 요청이 들어올 때마다 해당 토큰을 추출하고 유효성을 검증한다.
  • 토큰이 유효하면 해당 사용자의 인증 정보를 설정하여 요청 처리 과정에서 인증된 사용자로 인식된다.
  • 인증 정보가 설정된 후 요청은 다음 필터로 전달되어 계속 처리된다.

CustomUserDetailService

@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는 이 객체를 사용하여 입력된 비밀번호와 저장된 비밀번호를 비교하고, 인증을 처리한다.

Auth

DTO

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를 별도 파일로 분리하는 것이 더 나은 경우

  • DTO가 복잡한 경우: DTO 자체가 복잡한 로직이나 많은 필드를 가진 경우
  • 재사용성이 높은 경우: 여러 기능에서 공유되는 DTO
  • 코드 크기: 한 파일에 너무 많은 코드가 집중되는 경우

Application

@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 클래스다.

Controller

@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);
    }

}

Querydsl, Auditing 적용

@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를 반환하는 커스텀 구현체입니다.

회원가입 및 로그인 API 테스트


성공적으로 테스트가 끝나서 이제 RefreshToken 관련 코드를 추가해준다.

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

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();
    }
}

refreshTokenQRefreshTokenstatic 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);
}

Service

@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;
        }
    }

Controller

    @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("로그아웃 되었습니다.");
    }

토큰 재발급과 로그아웃에 대한 엔드포인트를 추가해준다.

Postman 테스트

Global Handler

공통 예외 클래스

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)가 필요 없다.
  • 필드

    • status: HTTP 상태 코드를 나타내는 HttpStatus 열거형 값이다.
    • errorCode: 오류 코드를 나타내는 문자열이다.

사용 목적

  • 일관된 예외 처리: 애플리케이션 전체에서 발생하는 예외를 일관된 형식으로 처리할 수 있게 한다.
  • HTTP 상태 코드 매핑: 예외가 발생했을 때 적절한 HTTP 상태 코드를 클라이언트에 반환할 수 있다.
  • 오류 코드 제공: 클라이언트가 오류의 구체적인 원인을 식별할 수 있는 코드를 제공한다.

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을 상속하는 인증 관련 예외의 기본 클래스이다.
  • 생성자를 통해 메시지, HTTP 상태 코드, 에러 코드를 상위 클래스에 전달한다.
  • 구체적인 인증 예외 상황들을 내부 정적 클래스로 캡슐화한다.

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을 만들고, 그 안에 구체적인 사용자 예외 상황들을 내부 클래스로 정의하고 있다.

API 응답 포맷

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 어노테이션을 사용하여 애플리케이션 전반에서 발생하는 다양한 예외를 일관된 형식으로 처리한다.

회원가입을 하지 않고 로그인 하는 경우 예시

API 응답 형식 표준화

현재 작성해둔 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, "로그아웃 되었습니다."));
    }
}
  • 회원가입

  • 로그인

profile
공부하는 초보 개발자

0개의 댓글