본문 바로가기
IT 프로젝트 공모전(백엔드)

[Spring boot]Spring Security와 JWT를 활용한 로그인 인증 시스템 구현

by CromArchive 2025. 8. 15.
반응형

1. 인증 시스템 아키텍처 개요

인증 시스템은 상태 비저장(Stateless) 아키텍처를 기반으로 한다.

서버는 사용자의 세션을 유지하지 않으며, 모든 요청은 암호화된 토큰(JWT)을 통해 인증된다.

이는 서버의 확장성을 높이고 클라이언트-서버 간의 의존성을 낮출 수 있다.

  • 주요 기술 스택:
    • Spring Boot 3: 애플리케이션의 기반 프레임워크이다.
    • Spring Security: 인증(Authentication)과 인가(Authorization)를 담당하는 핵심 보안 프레임워크이다.
    • JWT (JSON Web Token): 인증 정보를 안전하게 전송하기 위한 토큰 기술이다. io.jsonwebtoken 라이브러리를 사용한다.
    • JPA (Java Persistence API): 데이터베이스와 상호작용하며 User 및 RefreshToken 엔티티를 관리한다.

2. 로그인 처리 상세 흐름

사용자가 아이디와 비밀번호를 입력하여 로그인을 시도하는 과정은 다음과 같은 단계를 거친다.

1단계: 클라이언트 요청 및 컨트롤러 수신

  1. 사용자는 로그인 페이지에서 아이디(uid)와 비밀번호를 입력하고 로그인 버튼을 클릭한다.
  2. 클라이언트는 /v1/auth/login 엔드포인트로 HTTP POST 요청을 전송한다. 요청의 본문(Body)에는 JSON 형식의 사용자 자격 증명 정보가 포함된다.
  3. AuthController의 login 메서드가 이 요청을 수신한다. @RequestBody 어노테이션은 HTTP 요청 본문의 JSON 데이터를 LoginRequest DTO 객체로 변환(역직렬화)한다. LoginRequest DTO는 uid와 password 필드를 가지고 있으며, @NotBlank와 같은 유효성 검사 어노테이션을 통해 값이 비어있지 않은지 확인한다.
  4. AuthController는 실제 인증 로직을 직접 수행하지 않고, AuthService 인터페이스에 의존하여 authenticateAndCreateToken 메서드를 호출함으로써 인증 처리를 위임한다. 이는 계층 간 역할 분리원칙을 따른 것이다.

//로그인
    @PostMapping("/login")
    public ApiResponse<JwtResponse> login(@RequestBody @Valid LoginRequest dto){
        JwtResponse token = authService.authenticateAndCreateToken(dto);
        return ApiResponse.success(token, "200", "success");
    }

2단계: 서비스 계층에서의 인증 처리

AuthServiceImpl 클래스는 실질적인 인증 로직을 담당한다.

  1. 사용자 조회: UserRepository를 사용하여 LoginRequest에 담긴 uid로 데이터베이스에서 User 엔티티를 조회한다. 사용자를 찾을 수 없으면 RuntimeException을 발생시켜 응답을 중단한다.
  2. 비밀번호 검증: PasswordConfig에 빈으로 등록된 BCryptPasswordEncoder를 사용하여, 클라이언트로부터 받은 평문 비밀번호와 데이터베이스에 저장된 해시된 비밀번호를 matches() 메서드로 비교한다. 일치하지 않을 경우, RuntimeException을 발생시킨다.
  3. 토큰 생성 위임: 인증이 성공하면, JwtUtils를 호출하여 Access Token을 생성하고, RefreshTokenService를 호출하여 Refresh Token을 생성 및 저장한다.
public JwtResponse authenticateAndCreateToken(LoginRequest req){
        User user = userRepository.findByUid(req.getUid())
                .orElseThrow(() -> new RuntimeException("유저를 찾을 수 없음"));
        if(!passwordEncoder.matches(req.getPassword(), user.getPassword())){
            throw new RuntimeException("비밀번호가 일치하지 않음");
        }

        String accesstoken = jwtUtils.generateJwtToken(user);
        Long expiresIn = jwtUtils.getJwtExpirationMs();
        RefreshToken refreshTokenEntity = refreshTokenService.createRefreshToken(user);

        return new JwtResponse(accesstoken, "Bearer", expiresIn, refreshTokenEntity.getToken()  ,user.getUid());
    }

 

3단계: JWT 생성 및 관리

  1. Access Token 생성: JwtUtils의 generateJwtToken 메서드는 인증된 User 객체를 받아 Access Token을 생성한다. 토큰에는 주제(subject)로 사용자의 uid가, 추가적인 정보로 nickName이 포함된다. 발급 시간과 만료 시간(jwt.expiration-access 값)이 설정되며, HMAC-SHA 알고리즘과 비밀 키(jwt.secret)를 사용하여 서명된다.
  2. Refresh Token 생성 및 저장: RefreshTokenServiceImpl의 createRefreshToken 메서드는 먼저 해당 사용자의 기존 Refresh Token을 데이터베이스에서 삭제하여 한 번에 하나의 유효한 Refresh Token만 유지하도록 보장한다. 이후 UUID를 사용하여 무작위의 토큰 문자열을 생성하고, 만료 시간(jwt.expiration-refresh 값)을 설정하여 새로운 RefreshToken 엔티티를 생성한 후, RefreshTokenRepository를 통해 데이터베이스에 저장한다.

public String generateJwtToken(User user) {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        Key signingKey = Keys.hmacShaKeyFor(keyBytes);

        return Jwts.builder()
                .subject(user.getUid())
                .claim("nickName", user.getNickName())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
                .signWith(signingKey)
                .compact();
    }
@Override
    @Transactional
    public RefreshToken createRefreshToken(User user){
        refreshTokenRepository.deleteByUser(user);

        RefreshToken token = new RefreshToken(
                user,
                UUID.randomUUID().toString(),
                Instant.now().plusMillis(refreshTokenDurationMs)
        );
        return refreshTokenRepository.save(token);
    }


4단계: 클라이언트로 응답

  1. 생성된 Access Token, Refresh Token, 토큰 만료 시간, 사용자 uid 등의 정보를 JwtResponse DTO에 담는다.
  2. 이 JwtResponse 객체를 다시 ApiResponse DTO로 감싸서, 표준화된 응답 형식(성공 여부, 메시지, 데이터)으로 만들어 클라이언트에게 최종적으로 반환한다.

3. JWT 검증 및 인가 (요청 처리)

로그인 이후, 클라이언트는 보호된 API에 접근하기 위해 모든 요청의 Authorization 헤더에 Bearer 접두사와 함께 Access Token을 담아 전송한다.

  1. 필터 체인과 AuthTokenFilter: SecurityConfig에 정의된 보안 필터 체인에 따라, 모든 수신 요청은 UsernamePasswordAuthenticationFilter 이전에 위치한 AuthTokenFilter를 통과한다.
  2. 토큰 파싱 및 검증: AuthTokenFilter는 parseJwt 메서드를 통해 헤더에서 'Bearer ' 접두사를 제거하고 순수한 토큰 문자열을 추출한다. 추출된 토큰은 jwtUtils.isValidToken 메서드로 전달되어 서명의 유효성과 만료 여부를 검증받는다.
  3. SecurityContext 설정: 토큰이 유효하면, jwtUtils.getUsernameFromJwt를 통해 토큰에서 사용자 uid를 추출한다. UserDetailsServiceImpl은 이 uid를 사용하여 데이터베이스에서 사용자 정보를 조회하고, Spring Security가 이해할 수 있는 UserDetails 객체를 생성한다. 이 UserDetails 객체를 기반으로 UsernamePasswordAuthenticationToken이 생성되고, 최종적으로 SecurityContextHolder에 저장된다. 이제 해당 요청은 인증된 것으로 간주되며, 이후의 인가(Authorization) 결정에 사용된다.
public boolean isValidToken(String Token){
       try{
           Jws<Claims> jws = Jwts.parser()
                   .verifyWith(getSigningKey())
                   .build()
                   .parseSignedClaims(Token);
           return true;
       }
       catch (JwtException | IllegalArgumentException e){
           return false;
       }
    }

4. 토큰 재발급 메커니즘

Access Token의 유효 기간은 짧기 때문에 만료 시 새로운 토큰을 발급받는 과정이 필요하다.

  1. Access Token이 만료되면 클라이언트는 /v1/auth/refresh 엔드포인트로 Refresh Token을 담아 요청을 보낸다.
  2. AuthController는 RefreshTokenRequest DTO로 토큰을 수신하고, RefreshTokenService의 findByToken 메서드를 호출하여 데이터베이스에 해당 Refresh Token이 존재하는지 확인한다.
  3. verifyExpiration 메서드를 통해 DB에 저장된 Refresh Token의 만료 시간을 현재 시간과 비교한다. 만약 만료되었다면, 토큰을 DB에서 삭제하고 TokenRefreshException을 발생시킨다.
  4. 토큰이 유효하다면, JwtUtils는 Refresh Token에 포함된 사용자 정보로 새로운 Access Token을 생성하고, RefreshTokenService는 새로운 Refresh Token을 생성하여 DB를 갱신한다.
  5. 새롭게 발급된 토큰들을 JwtResponse에 담아 클라이언트에게 반환한다.
//Jwt 재발급
    @PostMapping("/refresh")
    public ResponseEntity<JwtResponse> refresh(@Valid @RequestBody RefreshTokenRequest req){
        RefreshToken stored = refreshTokenService.findByToken(req.getRefreshToken())
                .orElseThrow(() -> new TokenRefreshException(req.getRefreshToken(), "DB에 존재하지 않음"));
        refreshTokenService.verifyExpiration(stored);

        //DB에서 얻은 사용자로 새 AccessToken 생성
        String newAccess = jwtUtils.generateJwtToken(stored.getUser());
        RefreshToken newRefresh = refreshTokenService.createRefreshToken(stored.getUser());

        JwtResponse res = new JwtResponse(newAccess, "Bearer", jwtUtils.getJwtExpirationMs(),newRefresh.getToken(),stored.getUser().getUid());
        return ResponseEntity.ok(res);
    }

5. 예외 처리

체계적인 예외 처리는 안정적인 서비스를 위해 필수적이다.

  • AuthEntryPointJwt: 인증되지 않은 사용자가 보호된 리소스에 접근을 시도할 때 commence 메서드가 호출된다. 이는 클라이언트에게 HTTP 상태 코드 401(Unauthorized)과 함께 에러 메시지를 담은 ApiResponse를 반환한다.
  • RestExceptionHandler: @RestControllerAdvice를 통해 전역적으로 예외를 처리한다.
    • TokenRefreshException : Refresh Token 관련 오류 발생 시 403(Forbidden)을 반환한다.
    • JwtException : JWT 서명 오류, 형식 오류 등 발생 시 401(Unauthorized)을 반환한다.
    • AccessDeniedException : 인증은 되었으나 특정 리소스에 대한 권한이 없을 때 403(Forbidden)을 반환한다.
    • MethodArgumentNotValidException: @Valid 어노테이션을 사용한 DTO 유효성 검증에 실패했을 때 400(Bad Request)과 함께 실패한 필드와 이유를 상세히 반환한다.
/** 리프레시 토큰 검증 실패 → 403 */
    @ExceptionHandler(TokenRefreshException.class)
    public ResponseEntity<ApiResponse<Void>> handleTokenRefresh(TokenRefreshException ex) {
        ApiResponse<Void> body = ApiResponse.error("403", ex.getMessage());
        return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body(body);
    }

    /** JWT 서명/만료 등 검증 실패 → 401 */
    @ExceptionHandler(JwtException.class)
    public ResponseEntity<ApiResponse<Void>> handleJwt(JwtException ex) {
        ApiResponse<Void> body = ApiResponse.error(
                "401",
                "Invalid or expired JWT: " + ex.getMessage()
        );
        return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body(body);
    }

    /** 권한 부족 → 403 */
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
        ApiResponse<Void> body = ApiResponse.error(
                "403",
                "Access denied: " + ex.getMessage()
        );
        return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body(body);
    }

    /** @Valid 검증 실패 → 400 (Request DTO) */
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        // 필드별 에러 메시지만 추출
        String msg = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .collect(Collectors.joining("; "));

        ApiResponse<Void> body = ApiResponse.error("400", "Validation failed: " + msg);

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .headers(headers)
                .body(body);
    }

    /** 그 외 예외 → 500 */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleAll(Exception ex) {
        ApiResponse<Void> body = ApiResponse.error(
                "500",
                "Internal error: " + ex.getMessage()
        );
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(body);
    }
728x90
반응형