본문 바로가기
백엔드/스프링

[Spring] JWT 토큰과 Security 인증 인가 로직 복습

by AI읽어주는남자 2025. 10. 22.
반응형

JWT 토큰 및 Spring Security 인증/인가 로직 복습

이 문서는 제공된 소스 코드를 기반으로 Spring Boot 애플리케이션에서 JWT(JSON Web Token)와 Spring Security를 사용한 인증 및 인가 메커니즘을 설명하고, 관련 핵심 개념을 정리합니다.

0. 시작하기 전에: 핵심 개념 정의

인증 (Authentication)

  • 정의: 사용자가 누구인지 확인하는 과정입니다. (예: 아이디/비밀번호 로그인, 이메일 인증 코드 확인)
  • 목표: 시스템에 접근하려는 주체의 신원을 증명하는 것입니다.

인가 (Authorization)

  • 정의: 인증된 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 확인하는 과정입니다.
  • 목표: 신원이 증명된 사용자가 허용된 범위 내에서만 동작하도록 제어하는 것입니다.

1. 전체 인증/인가 흐름

애플리케이션은 상태 비저장(Stateless) 인증 방식을 사용합니다. 즉, 서버는 사용자 세션을 저장하지 않고 모든 요청에 포함된 JWT 토큰을 통해 사용자를 인증합니다.

  1. 사용자 로그인 (/api/user/login):

    • 클라이언트는 아이디와 비밀번호로 로그인을 요청합니다.
    • 서버는 UserService에서 BCrypt를 사용해 비밀번호를 검증합니다.
    • 인증에 성공하면 JwtService를 통해 JWT(Access Token)를 생성합니다. 이 토큰에는 사용자의 아이디(uid)와 역할(urole)이 포함됩니다.
    • 생성된 토큰은 HttpOnly 속성이 적용된 쿠키에 담겨 클라이언트에게 전송됩니다.
  2. API 요청:

    • 클라이언트는 API 요청 시 브라우저에 저장된 JWT 쿠키를 함께 전송합니다.
    • 서버의 JwtAuthFilter가 모든 요청을 가로채 쿠키에서 JWT 토큰을 추출합니다.
    • JwtService를 통해 토큰의 유효성을 검증합니다.
    • 토큰이 유효하면, 토큰에서 사용자 정보(아이디, 역할)를 추출하여 Spring Security가 이해할 수 있는 UsernamePasswordAuthenticationToken을 생성합니다.
    • 생성된 인증 객체를 SecurityContextHolder에 저장하여 현재 요청에 대한 사용자 인증을 완료합니다.
  3. 인가 (접근 제어):

    • SecurityConfig에 정의된 규칙에 따라, SecurityContextHolder에 저장된 사용자의 역할(ROLE_USER, ROLE_ADMIN 등)을 기반으로 요청된 API에 대한 접근 허용 여부를 결정합니다.
    • 권한이 충분하면 컨트롤러의 메서드가 실행되고, 그렇지 않으면 403 Forbidden 오류가 반환됩니다.
  4. 로그아웃 (/api/user/logout):

    • 클라이언트에게 만료 시간이 0인 동일한 이름의 쿠키를 전송하여 브라우저에서 토큰 쿠키를 즉시 삭제하도록 합니다.

2. 핵심 컴포넌트 분석

2.1. 비밀번호 암호화 (UserService.java)

  • 목적: 사용자의 비밀번호를 안전하게 저장하기 위해 단방향 암호화(해싱)를 사용합니다.
  • 구현: BCryptPasswordEncoder
    • 회원가입 시: bcrypt.encode(평문_비밀번호)를 호출하여 비밀번호를 해시 값으로 변환한 후 DB에 저장합니다.
      // UserService.java - signup()
      userDto.setUpwd( bcrypt.encode(userDto.getUpwd() ) );
      userMapper.signup( userDto );
    • 로그인 시: bcrypt.matches(평문_비밀번호, DB에_저장된_해시)를 사용해 입력된 비밀번호가 올바른지 검증합니다. matches 메서드는 내부적으로 동일한 알고리즘으로 평문을 해싱하여 DB의 해시와 비교합니다.
      // UserService.java - login()
      boolean result2 = bcrypt.matches(userDto.getUpwd(), result.getUpwd());
      if (result2 == true ) {
        // 로그인 성공
        return result;
      }

2.2. JWT (JSON Web Token) 관리 (JwtService.java)

  • 정의: JSON 형식의 데이터를 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다. 주로 인증과 권한 부여에 사용됩니다.

  • 장점:

    • HTTP 친화적: HTTP 헤더에 포함시켜 쉽게 전송할 수 있습니다.
    • 보안: 서명(Signature)을 통해 토큰의 변조 여부를 검증할 수 있습니다. (예: HS256 알고리즘)
    • 무상태(Stateless): 서버가 사용자 상태를 저장할 필요 없이 클라이언트가 토큰을 소유하므로 확장성이 좋습니다.
  • JWT의 구조: 헤더.페이로드.서명

    • Header (헤더): 토큰의 타입(JWT)과 서명에 사용된 알고리즘(예: HS256) 정보를 담습니다.
    • Payload (페이로드): 토큰에 담을 실제 정보(클레임)를 포함합니다. 사용자의 아이디, 역할, 토큰 발급 시간(iat), 만료 시간(exp) 등이 여기에 해당합니다.
    • Signature (서명): 헤더와 페이로드를 합친 후, 지정된 비밀키(secret key)로 암호화하여 생성합니다. 이 서명을 통해 토큰이 중간에 변경되지 않았음을 보장합니다.
  • 구현: io.jsonwebtoken (jjwt) 라이브러리

    • 의존성 추가:
      // build.gradle
      implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
      runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
      runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
    • 비밀키 설정: 토큰의 서명을 생성하고 검증하는 데 사용되는 32바이트 이상의 비밀키(secret)를 정의합니다. 이 키는 외부에 노출되어서는 안 됩니다.
      // JwtService.java
      private String secret = "80419273645108293746518204937465"; // 예시 키
      private Key secretKey = Keys.hmacShaKeyFor( secret.getBytes( StandardCharsets.UTF_8) );
    • 토큰 생성 (createToken): 로그인 성공 시 호출되며, 사용자의 아이디(uid)와 역할(urole)을 '클레임(claim)'으로 포함하여 토큰을 생성합니다.
      // JwtService.java - createToken()
      public String createToken( String uid , String urole ){
        String token = Jwts.builder()
                .claim("uid" , uid )
                .claim("urole" , urole)
                .setIssuedAt( new Date() ) // 발급 시간
                .setExpiration( new Date( System.currentTimeMillis() + 1000 * 60 * 60) ) // 1시간 후 만료
                .signWith( secretKey , SignatureAlgorithm.HS256)
                .compact();
        return token;
      }
    • 토큰 검증 및 클레임 추출: checkToken, getClaims 등의 메서드를 통해 토큰의 유효성을 검사하고 uidurole 같은 저장된 데이터를 추출합니다.

2.3. 요청 필터링 및 인증 (JwtAuthFilter.java)

  • 목적: 모든 API 요청을 가로채서 JWT를 통한 인증을 수행하고, Spring Security 컨텍스트에 인증 정보를 설정합니다.

  • 구현: OncePerRequestFilter를 상속받아 구현합니다.

    1. 쿠키에서 토큰 추출: request.getCookies()를 통해 loginUser라는 이름의 쿠키를 찾고, 그 값을 토큰으로 사용합니다.

    2. 토큰 검증 및 정보 추출: jwtService.checkToken(token)으로 토큰을 검증하고, jwtService.getUid(), jwtService.getUrole()로 정보를 가져옵니다.

    3. Spring Security 인증 객체 생성: 추출된 uidurole을 사용하여 UsernamePasswordAuthenticationToken을 생성합니다. 이 객체는 Spring Security에게 현재 사용자가 누구이며 어떤 권한을 가졌는지 알려주는 역할을 합니다.

      // JwtAuthFilter.java - doFilterInternal()
      String uid = jwtService.getUid( token );
      String urole = jwtService.getUrole( token );
      
      UsernamePasswordAuthenticationToken t =
              new UsernamePasswordAuthenticationToken(
                      uid , null , // 비밀번호는 null 처리
                      List.of( new SimpleGrantedAuthority("ROLE_"+urole) ) );
    4. SecurityContext에 저장: 생성된 인증 객체를 SecurityContextHolder에 저장합니다. 이로써 현재 요청 스레드 내에서 사용자가 인증된 것으로 간주됩니다.

      // JwtAuthFilter.java - doFilterInternal()
      SecurityContextHolder.getContext().setAuthentication( t );

2.4. Spring Security 설정 및 인가 (SecurityConfig.java)

  • 정의: Spring 기반 애플리케이션의 인증과 인가를 제공하는 라이브러리입니다. 로그인, 로그아웃, CSRF 방지, 토큰 기반 인증 등 다양한 보안 기능을 지원합니다.

  • 의존성 추가:

    // build.gradle
    implementation 'org.springframework.boot:spring-boot-starter-security'

    * 의존성을 추가하는 즉시 다수의 보안 필터가 기본적으로 활성화됩니다. 따라서 커스텀 설정이 필요합니다.

  • 구현: @ConfigurationSecurityFilterChain 빈을 사용해 보안 정책을 설정합니다.

    • 세션 정책 설정: JWT 기반의 상태 비저장(stateless) 인증을 위해 세션을 사용하지 않도록 설정합니다.
      // SecurityConfig.java
      http.sessionManagement(session -> session.sessionCreationPolicy( SessionCreationPolicy.STATELESS ) );
    • CSRF 보호 비활성화: 상태 비저장 API에서는 일반적으로 CSRF 보호를 비활성화합니다. (개발 단계에서 권장)
      // SecurityConfig.java
      http.csrf( csrf -> csrf.disable());
    • 커스텀 필터 등록: 직접 구현한 JwtAuthFilter를 Spring Security의 필터 체인에 추가합니다. UsernamePasswordAuthenticationFilter보다 먼저 실행되도록 설정하여 JWT 인증이 먼저 처리되게 합니다.
      // SecurityConfig.java
      http.addFilterBefore( jwtAuthFilter , UsernamePasswordAuthenticationFilter.class );
    • 경로별 접근 권한 설정 (인가): authorizeHttpRequests를 통해 각 URL 패턴에 필요한 역할을 정의합니다.
      // SecurityConfig.java
      .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/user/info").hasAnyRole("USER" , "ADMIN")
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers("/**").permitAll() ); // 그 외 모든 경로는 인증 없이 허용
      • 참고: SecurityConfig에서 전역으로 설정하는 대신, 각 컨트롤러 메서드에 @PreAuthorize("hasRole('ADMIN')") 어노테이션을 사용하여 개별적으로 권한을 설정할 수도 있습니다.

3. JWT 확장 및 고려사항

Redis를 이용한 토큰 관리

  • 목적: 여러 서버 인스턴스 간에 로그인 상태(토큰 정보)를 공유해야 할 때 사용됩니다.
  • 예시: 8080 포트의 회원 서비스, 8081 포트의 게시판 서비스가 독립적으로 운영될 때, 한 곳에서 로그인하면 다른 서비스에서도 로그인 상태가 유지되도록 할 수 있습니다.
  • 동작: 로그인 시 발급된 JWT(또는 Refresh Token)를 Redis와 같은 중앙화된 NoSQL DB에 저장합니다. 각 서버는 요청이 들어올 때마다 Redis를 조회하여 토큰의 유효성을 검증함으로써 상태를 공유합니다. 이는 토큰을 강제로 만료시키는 등 정교한 제어를 가능하게 합니다.
반응형