JWT 기반 인증 전략 및 설계 및 구현

Dong·2025년 2월 11일

1차프로젝트

목록 보기
4/4

이번에는 로그인쪽 JWT를 통해서 로그인을 구현해볼 예정이다 앞 글 JWT 세션쿠키 전략 및 구현에서 로그인에서 주요 사용되는 것을 공부했고 그 한계점까지 알아봤다.

만약 이글을 보고있으면 앞글을 보고오는게 좋을거같다

들어가며

보통 기술 구현에만 집중하다 보면 잊게되는데 내가 JWT를 구현하면서 궁금하게된 리스트 목록이다

  1. JWT 로그인에 사용자 UX를 고려할게 있나?
  2. JWT 로그인 보안 전략이랄게 있나? Access, refresh token으로 구현하는거 아닌가?

실제로 토큰을 구현하다보면 사용자 UX를 세울일이 많아진다 UX를 고려하지않으면 사용자가 자주 로그인 해야하는 경우가 생긴다 그렇다고 사용자 UX만 고려하자니 사용자의 정보 탈취 가능성도 높아진다 편의와 보안은 Trade off인경우가 많다 jwt 토큰 또한 그렇다

그럼 여기서 또 궁금한게 생긴다

  • JWT 토큰은 뭘까? 왜 사용할까? 장단점은?

  • JWT의 단점을 보완하는 전략은?

  • 우리 프로젝트에 맞는 JWT 보안 전략은?

    JWT

    Json Web Token의 약자로 일반적으로 클라이언트와 서버 사이에 통신할때 권한을 위해 사용하는 토큰이다 웹 상에서 정보를 Json 형태로 주고 받기 위해서 표준 규약에 따라 생성한 암호화된 토큰으로 복잡하고 읽을 수 없는 String 형태로 저장되어있다.

  1. 클라이언트 → 서버: 로그인 요청

    • 클라이언트가 아이디와 비밀번호를 서버로 전송.
  2. 서버: 사용자 인증 및 토큰 발급

    • 서버는 DB에서 사용자 정보를 확인한 후, JWT 생성 후 반환.
  3. 클라이언트: 토큰 저장 및 인증 요청 시 포함

    • 클라이언트는 받은 토큰을 저장하고, 이후 요청마다 포함하여 전송.
  4. 서버: 토큰 검증 및 요청 처리

    • 서버는 전달된 토큰을 검증 후, 유효하면 요청을 처리.

JWT 구성

JWT는 아래 사진과 같이 세가지 파트로 구성되어있다

  1. Header: 헤더는 보통 암호화 알고리즘의 종류와 토큰의 종류로 이뤄짐
    • 토큰의 메타 데이터가 들어가있음
  2. Payload: 토큰에 담을 정보가 들어가있다. 여기에 담는 정보의 한 조각을 클레임이라고 부르고 이는 Json(Key/Value) 형태의 한 쌍으로 이뤄져있다. 토큰에는 여러개 클렘들을 넣을수있다. JWT의 Payload 원문은 마지막에 Base64로 인코딩 되기 때문에 쉽게 복호화가 될 수 있다 즉 악의적으로 탈취 되었을 때 보안에 중요한 데이터가 있으면 문제가 될수도있다.
  3. Signature: Header와 Payload와 HMAC SHA256 혹은 비대칭 키를 이용해사 만든 해싱 값이다 여기서 Signature는 말 그대로 작성자의 신원을 보장하는 역할을 한다. 즉, 인증 역할을 수행하는 파트이다. 인증의 목적이 있기 때문에 비대칭 키를 사용한다. 개인키는 작성자만 가지고 있다. SHA256은 해싱 알고리즘이지만, HMAC SHA256은 암호화 키를 기반으로 한 해싱 알고리즘이다. 해싱할 데이터와 작성자만이 알 수 있는 secret key를 가지고 해싱을 하는 알고리즘이다. Secret key는 작성자만 알고 있기 때문에 해당 알고리즘 또한 인증에 사용될 수 있다.

여기서 SHA256과 HMAC 256차이는??

SHA256은 단순한 해싱 알고리즘으로, 입력된 데이터를 고정된 길이의 해시 값으로 변환하는 역할을 한다. 이는 데이터의 무결성을 검증하는 데 사용되지만, 단독으로는 인증 기능을 제공하지 않는다. 반면, HMAC SHA256은 해싱 과정에서 암호화 키(secret key)를 추가로 사용하는 알고리즘이다. 이 방식은 단순한 SHA256 해시와 달리, 특정한 키를 알고 있는 경우에만 동일한 해시 값을 생성할 수 있도록 한다. 따라서 데이터의 무결성뿐만 아니라, 인증 기능도 함께 제공할 수 있다.

장점

  • 서버에 부담이 없다(서버는 발행과 인증만 하면 되기 때문)
  • 데이터 조작이 감지될 수 있다.

단점

  • 탈취도면 지속적으로 악용될 수 있다.

JWT는 클라이언트의 저장소에 의지한다는 점에서 쿠키와 비슷하지만, 데이터 조작 검증 가능성에서 차이가 있다. JWT는 HMAC SHA256 알고리즘 혹은 비대칭키를 사용하기 때문에 해당 토큰이 자신이 발급한 객체인지 확인할 수 있다. 하지만 쿠키와 비슷한 단점이 여전히 존재한다. 탈취되면 지속적으로 악용될 수 있다는 점이다. 이를 막으려면 어떻게 해야 할까? '지속성'을 조정해주거나, '악용'을 막는 방법이 있다. 악용을 막기는 어렵다. 악의적인 사용자는 선량한 사용자의 네트워크를 엿들을 것인데, 이를 서버가 막기는 어렵다. 그렇다면 '지속성'을 조정해주는 방법이 있다. 이를 이용하여 JWT의 단점을 보완하는 방법에 대해 알아보자.


Acess token

Access Token은 앞서 언급한 JWT의 '지속성' 문제를 보완하는 방법이다. 즉, 만료 기한을 두어 토큰이 악용될 위험을 최소화하는 방식이다. 일반적으로 Access Token의 만료 시간은 30분 내외로 짧게 설정한다.

서버는 토큰을 발급할 때 만료 기한(exp) 을 함께 포함하여 발급하고, 이후 사용자 요청이 들어오면 Access Token의 만료 기한을 확인하여 유효성을 검사한다.

그런데 이런 의문이 들 수 있다.
"만료 기한을 조작하면 어떻게 되지?"

좋은 질문이다!
JWT에서는 만료 기한(exp)이 토큰의 payload에 포함되며, 서명(Signature) 생성 시 함께 사용된다. 즉, 데이터가 조작되면 서명이 달라지기 때문에 토큰 인증이 실패한다. 따라서 Secret Key 또는 개인 키가 탈취되지 않는 이상, JWT의 데이터는 조작될 수 없다.

장점

  • 서버에 부담이 없다
  • 데이터의 조작이 감지될 수 있다.
  • 탈취로 인한 피해 최소화(짧은시간)

단점

  • 사용자 UX 감소(만료기한이 지날 때 마다 로그인 해야함)

보안 강화 후 발생한 UX 문제

이제 Access Token을 사용하여 탈취 피해를 줄이는 데 성공했다. 웹 서비스에 적용해보니 해킹 피해는 줄어들었지만, 예상치 못한 문제가 발생했다.

"사용자들의 불만이 폭주한다!"
"30분마다 로그인해야 한다니 너무 불편하다!"
"이럴 거면 차라리 안 쓰겠다!"

보안만 신경 쓴 결과, 사용자 경험(UX)이 최악이 되어버린 것이다.

사실, 보안과 사용자 경험(UX)은 보통 반비례 관계이다.
보안을 강화하면 사용자는 불편해지고, 사용자를 편하게 하면 보안이 취약해질 위험이 높아진다.

따라서 이제 사용자 경험(UX)을 개선하는 방법을 고민해야 한다.
본격적으로 보안과 UX 사이의 균형을 맞출 방법을 정리해보자

Sliding session

Access Token을 짧게 설정하면 보안은 강화되지만, 사용자는 자주 로그인을 해야 하는 불편함을 겪게 된다. 이를 해결하기 위해 사용자가 계속해서 활동하면 세션을 자동으로 연장하는 방식이 Sliding Session이다.

즉, Access Token의 만료 시간이 다가오면, 새로운 토큰을 자동으로 발급하여 연장하는 방식이다.

Sliding session을 이용하니 UX적으로 불편한것은 줄였다 하지만 해킹피해가 발생했다 Sliding session은 사용자의 유의미한 이벤트로 만료기한을 늘려주기 때문에 자칫하면 만료기한이 무한정 늘어날 수 있는 단점이 있다. 사용자 UX를 개선시키니 보안이 다시 악화됐다. 토큰 하나만으로는 보안과 UX적으로 둘다 좋게 만드는데 한계가 있다 그 결과 많은 사람들은 refreshtoken을 사용한다

장점

  • 서버에 부담이없다
  • 데이터 조작이 감지될 수 있다.
  • 사용자 UX감소

단점

  • 탈취로 인한 피해 증가 (만료기간 무한정 연장가능)

Refresh token

Refresh token은 access token보다 만료 기한을 더 크게 설정하여 그 결과 네트워크에 덜 보내게 되어 탈취 가능성을 낮추는 원리가 적용된 인증 데이터이다. Refresh token을 이용하여 access token을 새로 발급 받는 방식이라고 생각하면 된다. refresh token은 만료 기한이 더 길기 때문에 네트워크에 노출되는 빈도가 access token 보다 훨씬 낮다(2주 > 30분). 따라서 탈취 가능성 자체도 낮아지게 되는거고 또한 Refresh token은 서버에도 저장된다

기술 선택 및 기능 구현

기술 선택

이제 어느정도 개념에대한 공부를 했으니 기술을 선택할 시간이다 각자 팀 상황의 맞게 잘 선택하는게 중요하다고 생각한다 우리는 1차프로젝트가때 기본 CRUD이기도 해서 refreshtoken과 accesstoken을 구현해서 refreshtoken은 쿠키에 담고 accesstoken은 responseBody에 보내기로 결정했다

로그인 기능

로그인을 구현하려면 두가지 기능이 필요하다 인증과 인가이다 사용자가 서버와 연관된 등록된 사용자인가를 판별하는데 인증, 특정 리소스에 접근할 수 있는 권한이 있는가를 판별하는게 인가이다 인증과 인가를 둘다 필터혹은 인터셉털르 사용해서 구현할 수 있지만 Filter를 사용하여 반복되는 응답로직을 security를 사용하는게 개발 편의성 향상으로 이어진다고 생각한다.

Spring Security 사용 여부에 따른 인증/인가 처리 방식

Spring을 사용할 때, Spring Security를 적용하는지 여부에 따라 인증(로그인)과 인가(권한 관리) 방식을 다르게 구성할 수 있다.


1. Spring 사용 O, Spring Security 사용 X

사용자 인증 (Authentication)

  • 필터(Filter)와 인터셉터(Interceptor) 둘 다 가능
  • 필터는 DispatcherServlet 실행 전에 동작하고, 인터셉터는 실행 후 동작
  • 어떤 방식을 선택해도 상관없지만, 비즈니스 로직과 인증을 분리하기 위해 필터에서 하는 경우가 많음

사용자 인가 (Authorization)

  • 인터셉터에서 처리하는 것이 좋음
  • 이유: URL 정보를 기반으로 권한을 체크하려면 DispatcherServlet 이후에 제어하는 것이 유리함
  • 예를 들어, 요청된 URL과 사용자의 권한 정보를 매핑하여 접근을 제한하는 방식

Custom 응답 리턴

  • 필터와 인터셉터 모두 가능
  • 단, 사용자 인증보다 앞에서 처리해야 함 (예: 인증 실패 시 즉시 응답)

2. Spring 사용 O, Spring Security 사용 O

사용자 인증 (Authentication)

  • 필터(Filter)에서 하는 것이 좋음
  • Spring Security는 기본적으로 Filter에서 인증을 수행하기 때문에, 인증 로직을 동일한 Level에서 처리하는 것이 유리함

사용자 인가 (Authorization)

  • Spring Security의 인가 기능을 그대로 사용
  • Spring Security는 Filter에서 인가(Authorization) 처리를 담당하므로, 별도로 구현할 필요 없음

Custom 응답 리턴

  • Spring Security의 필터에서 처리하는 것이 좋음
  • 이유: Spring Security가 필터 단계에서 요청을 차단하므로, 응답 커스터마이징도 같은 필터 레벨에서 이루어져야 함

사용자 인증 과정 (JWT 기반 Spring Security 인증)

Spring Security에서 기본적으로 /login 요청이 들어오면, UsernamePasswordAuthenticationFilter가 동작하여 인증을 처리한다. 이 필터는 attemptAuthentication 메서드를 실행하여 사용자의 아이디와 비밀번호를 검증하고, 인증이 성공하면 Authentication 객체를 생성한다.

하지만, JWT 기반 인증을 적용하기 위해서는 이 과정이 변경되어야 한다.

현재 코드에서는 UsernamePasswordAuthenticationFilter 대신 JwtAuthenticationFilter를 사용하여 인증을 수행한다. Spring Security의 필터 체인을 활용하면서도 세션 기반 인증이 아닌 JWT 인증 방식으로 변경한 것이다.

JwtAuthenticationFilter클라이언트의 요청 헤더에서 JWT를 추출하고, 유효성을 검증하여 인증을 수행하는 역할을 한다. 이를 통해 Spring Security의 기본 인증 방식(세션 기반 로그인) 없이도, JWT를 활용한 인증이 가능하다.

Spring Security를 사용하지 않았다면, 요청을 가로채고 인증을 수행하는 과정을 직접 구현해야 했을 것이다. 하지만 프레임워크의 동작 원리를 이해하고 활용하면, addFilterBefore 메서드를 사용하여 기존 인증 필터 앞에 JWT 인증 필터를 배치하는 방식으로 확장할 수 있다.

JWT 토큰 생성과 검증 (JwtTokenProvider 구현)

Spring Security에서 기본적으로 세션 기반 인증을 지원하지만, 나는 JWT 기반 인증을 사용하기 위해 별도의 토큰 관리 클래스를 구현했다.
그 역할을 하는 것이 바로 JwtTokenProvider이다.


JWT 생성 (AccessToken & RefreshToken)

Spring Security의 기본 로그인 방식이 아닌,
인증이 성공하면 직접 createToken()을 호출하여 JWT를 생성하는 방식으로 구현했다.

// JWT 생성 (Access Token)
public String createToken(long id) {
    Claims claims = Jwts.claims().setSubject(String.valueOf(id)); // 사용자 ID를 subject에 저장
    Date now = new Date();
    Date validity = new Date(now.getTime() + validityInMilliseconds); // 6분 유효

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(secretKey, SignatureAlgorithm.HS256) // HS256 알고리즘 사용
            .compact();
}

인증이 성공하면 JWT를 생성하여 반환하는 메서드
JWT의 subject에 사용자 ID 저장
유효 기간: 6분

// JWT (Refresh Token) 생성
public String createRefreshToken(long id) {
    Claims claims = Jwts.claims().setSubject(String.valueOf(id));
    Date now = new Date();
    Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds); // 7일 유효

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(secretKey, SignatureAlgorithm.HS256)
            .compact();
}

Refresh Token 생성 메서드
Access Token보다 더 긴 7일 유효 기간 설정


JWT 검증 (유효성 체크)

클라이언트가 보낸 JWT가 유효한지 검증하는 과정도 필요하다.

// JWT 유효성 검사
public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token);
        return true; // 유효하면 true 반환
    } catch (Exception e) {
        return false; // 유효하지 않으면 false 반환
    }
}

JWT의 유효성을 검증하는 메서드
만료된 토큰이거나 변조된 경우 예외 처리하여 false 반환


JWT에서 사용자 ID 추출

// JWT에서 ID 추출
public long getId(String token) {
    String id = Jwts.parserBuilder()
            .setSigningKey(secretKey)
            .build()
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
    return Long.parseLong(id); // String을 long으로 변환하여 반환
}

블로그 추가 내용:


Spring Security에서 JWT 인증 필터 적용하기

이제 JwtAuthenticationFilter를 구현하여 요청마다 JWT를 검증하고 사용자 정보를 SecurityContextHolder에 설정하는 과정에 대해 설명하겠다.

JWT 인증 필터 (JwtAuthenticationFilter) 추가

OncePerRequestFilter를 상속하여 모든 요청에서 한 번씩 실행되는 필터를 구현했다.

package com.example.coffee.common.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = getTokenFromHeader(request);
        if (token != null && jwtTokenProvider.validateToken(token)) {
            long userId = jwtTokenProvider.getId(token);

            UserDetails userDetails = new CustomUserDetails(userId);
            // Spring Security 인증 객체 설정
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities()
            );
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    private String getTokenFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

UserDetails 구현 (CustomUserDetails) 추가

Spring Security는 사용자 정보를 관리하고 인증을 처리할 때, 기본적으로 UserDetails 인터페이스를 사용한다.
JWT 기반 인증에서도 SecurityContextHolder에 저장할 사용자 정보가 필요하기 때문에 UserDetails를 구현하는 것이 중요하다.
Spring Security는 요청을 처리할 때, 인증된 사용자 정보를 SecurityContextHolder에 저장한다.
이때, UserDetails 객체를 저장하는 것이 일반적이며, 그렇지 않으면 Spring Security가 정상적으로 사용자 정보를 인식하지 못할 수 있다.

package com.example.coffee.common.jwt;

import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
    private final long userId;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return null; // 비밀번호를 저장하지 않으므로 null 반환
    }

    @Override
    public String getUsername() {
        return String.valueOf(userId); // 사용자 ID를 문자열 형태로 반환
    }

    @Override
    public boolean isAccountNonExpired() {
        return true; // 계정이 만료되지 않았다고 가정
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 계정이 잠기지 않았다고 가정
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true; // 인증 정보가 만료되지 않았다고 가정
    }

    @Override
    public boolean isEnabled() {
        return true; // 계정이 활성화되어 있다고 가정
    }
}

JwtAuthenticationFilter와 CustomUserDetails 적용

이제 JwtAuthenticationFilter가 요청을 가로채 JWT를 검증하고, CustomUserDetails를 생성하여 SecurityContextHolder에 저장하는 방식으로 인증을 처리할 수 있다.

이제 인증된 사용자는 SecurityContextHolder.getContext().getAuthentication()을 통해 언제든지 접근할 수 있으며, 컨트롤러에서도 @AuthenticationPrincipal을 활용하여 현재 로그인한 사용자의 정보를 가져올 수 있다.


로그인API

로그인 API를 직접구현해서 accesstoken은 response로 refreshtoken은 따로 쿠키로 보내기로 결정했다 그 이유는 간단한 프로젝트이기도하고 서버에 요청필요없이 바로바로 클라이언측에서 요청할수있기때문에 결정을 내렸다 하지만 나중에 시간이된다면 서버로 설계를 바꿔도 좋을거 같다는 생각이 든다

인가처리


✅ JWT가 인증된 사용자만 접근 가능
✅ @AuthenticationPrincipal을 사용해 현재 인증된 사용자 정보를 가져옴
✅ JWT가 없거나 유효하지 않으면 401 Unauthorized 응답


출처

https://velog.io/@kskim625/%EC%9D%B8%EC%A6%9D-access-refresh-token-%EB%B0%A9%EC%8B%9D
https://seungwoolog.tistory.com/95

profile
소통을 잘하는 백엔드 개발자가 되기 위해, 꾸준히 성장하고 기록중입니다.

0개의 댓글