Spring Security JWT Token 인증 방식 구현하기

이번에 신규 프레임워크를 작업하기로 하면서 기존 아주 올드한 프레임워크에서 큰 변화가 있었다. 물론 아직 초기 단계이기에 중간에 엎어질 수도, 다른 방식을 사용할 수도 있지만, 중요한 건 스프링 시큐리티 도입 테스트이다.

기존 프레임워크의 단점은 로그인 인증 포인트가 2군데 였다. 첫번째는 내부 로그인, 두번째는 외부 연동 SSO 로그인이었다. 그 중 SSO 로그인의 경우, 우리 쪽 서비스가 아닌 외부 SSO 연동 후 인증을 진행하고 SSO에서 성공이 떨어지면 내부 로그인을 자동으로 시켜주는 구조였다.

그런데 이런 구조가 전부 내부 로그인 컨트롤러 하나에 몰려있다보니, 로그인 컨트롤러의 역할이 증가하고, 커스텀 되는 부분이 많았다. 이는 결국 과도한 책임과 역할을 가져왔고 유지보수에 큰 비중을 차지하게되었다.

그래서 이번 프로젝트에서는 내부 로그인 구간과 외부 로그인 연동 구간을 분리작업하기로 하였고, 외부에서 들어오는 로그인 요청의 경우 한 엔드포인트에서 JWT 토큰 인증 방식을 통해 인증하고, 인증이 완료되면 내부 세션을 연동 시켜주는 엔드포인트로 리다이렉트 시켜주는 구조를 만들어보기로 했다.

이렇게 하면 어떤 외부 연동 구간이 와도, 연동 후에 JWT 토큰 발급 수행 후, 내부 연동 구간으로 찌르기만 하면 되는 구조를 가지게 되는 것이다.

Spring Security 는 처음인데요?

그러나 한가지 큰 문제는 내가 스프링 시큐리티를 처음 써본다는 것이었다. 예전부터 존재는 알고 있었으나, 어렵고 SI성 프로젝트에는 맞지 않는다는 얘기를 자주 들어서 사실 그렇게 크게 관심을 두지 않았다. 오히려 대기업에서는 자체적으로 로그인 인증 서버를 구축하고 있다보니, 큰 기업에서도 사용하는 것을 보지 못했다.

어쨌든 JWT Token 인증 방식으로 구현하기로 했으니 책을 사서 보면서 시작했다.

JWT Token 인증 방식

기본적으로는 Json Web Token 이라는 토큰을 가지고 사용자를 인증하는 방식이다. 서버는 세션을 생성 및 사용하지 않으며 오로지 이 토큰을 가지고 인증하는 구조이다.

따라서, 사용자는 로그인 후, 해당 토큰을 발급 받고 이후 요청에는 해당 토큰을 요청에 실어보내서 이 요청이 올바르게 인증을 거친 사용자라는 걸 증명하고, 부여된 권한에 따라 응답을 받는 구조이다.

다만, 이 토큰 값으로만 인증을 하다보니 중간에 탈취가 되는 경우 보안에 문제가 발생하기 때문에 보통 이 인증에 사용된 토큰(이하, 액세스 토큰) 과 리프레시 토큰이라는 액세스 토큰을 갱신할 수 있는 토큰을 가지고 운영하고, 각 토큰에 만료기간을 설정한다.

100% 안전한 것이 아니고, 공격을 무조건 방지할 수 없다보니 만료기간을 짧게 가져가고 리프레시 토큰을 이용해 액세스 토큰 발급을 유도한다.

JWT 인증 방식에 대한 개념 및 장단점 등은 인터넷에 정리된 자료가 많으니 찾아보도록 하자

Spring Security 사전 학습

어느정도 개념은 알았고 시큐리티를 통해 구현하기 위해 시큐리티의 전반적인 동작 과정 및 개념을 살펴보았다. 구글링을 통해 본 결과는, 사람들이 구현한 것이 정말 제각각이고 어떤게 맞는 것인지도 모르겠으며 너무 단편적이기 때문에 이렇게 하는게 보편적이다 - 하는 방식을 찾기가 힘들었다.

공식 문서에서는 되게 추상적으로만 알려주어서 이렇게 구현해라! 라는 부분이 없어 조금 힘들었다. 결국 열심히 보고 구현 해보는 수밖에 …

Spring Security?

스프링 시큐리티는 5.7 버전 이상 부터는 WebConfigurer 방식에서 filterChain 방식으로 변화하였다. (인터넷에는 Configurer를 상속 받는 예제가 너무 많다 …)

필터 체인 방식은, 여러 개의 각기 다른 역할을 하는 필터가 인터페이스 혹은 그 인터페이스를 구현한 구현체로 존재하고 설정에 따라 해당 필터를 조합하여 인증 체계를 갖추는 형태를 말한다.

당연하게도 해당 필터는 그대로 사용하거나, 상속 또는 확장하여 원하는 대로 커스텀하여 적용할 수 있다. 아마 이게 스프링 시큐리티의 최대 장점이라고 생각한다. 원하는 필터 또는 클래스만 구현하면 나머지 불 필요한 작업(인증 완료 처리, 성공/실패 처리, 등등)은 시큐리티에 기본 내장된 필터 및 객체 등의 플로우에 따라 처리 되기 때문에 편리하다고 생각한다. (이런 부분이 나중에 단점으로 다가 오게 되는데 …)

스프링 부트 내에서는 아래 사진과 같이, 필터 사이에 있는 DelegatingFilterProxy로 래핑된 FilterChainProxy가 요청을 SecurityFilterChain 으로 위임시켜 시큐리티가 요청에 대해 필터링을 할 수 있게끔 처리한다. Pasted image 20240403013214.png 그리고 시큐리티 필터 체인에 구성된 시큐리티의 필터들은 각자 인증/인가를 위한 처리를 진행한다. 우리 같은 사용자는 인증/인가 처리를 위해 이 시큐리티 필터 체인을 구성하는 것이 목적이라고 할 수 있다.

JWT Token 인증 방식의 흐름

이 상태에서 그렇다면 나는 어떻게 인증 흐름을 구현할 것인가에 대해 생각했다.

아직 초기 단계에 파일럿 프로젝트이기 때문에 인가에 대한 부분은 제외했다. 사실 구축하고자 하는 프로젝트 내 인가 관련 부분은 많이 복잡할 예정이라서, 시큐리티에서 컨트롤하는 것은 제약사항이 많다고 판단했다. 2뎁스 이상 이어질 수도 있고 멀티 신분 등의 요소가 있기 때문이다.

그래서 여기서 고려된 부분은 오로지 “인증”만 하는 것을 목표로 했다. 내가 생각한 진행은 다음과 같다. Pasted image 20240404005653.png 위 2가지 사항 외에도 다른 구현할 게 많을 것 같았지만, 우선 2가지라도 구현해보고자 했다.

JWT Token 인증 구현

구현해야 될 클래스는 다음과 같다.

  • CustomUserDetails implements UserDetails
    • 사용자의 인증 정보를 담기 위한 UserDetails의 구현체
  • JwtAuthenticationFilter extends OncePerRequestFilter
    • 토큰은 매 요청마다 검사해야 하기 때문에, OncePerRequestFilter를 확장하여 구현한다.
  • JwtTokenProvider(여기서 Provider는 AuthenticationProvider 가 아니다.)
    • 토큰에 대한 책임을 갖는 유틸 클래스
  • 사용자 인증에 필요한 서비스/레파지토리
    • 기타 사용자 조회 및 검증을 위한 클래스

build.gradle

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
  
    // jpa  
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'  
  
    // devtools  
    developmentOnly 'org.springframework.boot:spring-boot-devtools'  
  
    /* boot starter web */  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
  
    /* Spring Security */  
    // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security  
    implementation 'org.springframework.boot:spring-boot-starter-security'  
  
    // Json Web Token  
    implementation 'io.jsonwebtoken:jjwt:0.9.1'  
  
    /* h2 database */  
    runtimeOnly 'com.h2database:h2'  
  
    /* lombok */  
    compileOnly 'org.projectlombok:lombok'  
    annotationProcessor 'org.projectlombok:lombok'  

CustomUserDetails

사용자의 인증 정보를 저장하기 위한 객체이다. vaildation 용 메소드는 따로 구현하지 않았다. 추가하고자 하는 정보가 있으면 멤버 변수로 선언하여 더 넣을 수 있다.

@Setter  
@Getter  
@NoArgsConstructor  
@AllArgsConstructor  
@ToString  
public class CustomUserDetails implements UserDetails {  
  
    // Getter와 Setter    private String username;  
    private String password;  
    private String accessToken;  
    private String refreshToken;  

	// 여기서 Users는 h2-database 에 저장한 사용자 객체이다.
	// 다른 객체로 교체해도 된다.
    public CustomUserDetails(Users users) {  
        this.username = users.getUsername();  
        this.password = users.getPassword();  
    }  
  
    // UserDetails 인터페이스 구현  
    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
        return null; // 권한 관련 처리 필요  
    }  
  
    @Override  
    public String getPassword() {  
        return this.password;  
    }  
  
    @Override  
    public String getUsername() {  
        return this.username;  
    }  
  
    @Override  
    public boolean isAccountNonExpired() {  
        // TODO 계정 만료 체크  
        // = 계정에 사용기한 있는 경우에만 구현.  
        return true;  
    }  
  
    @Override  
    public boolean isAccountNonLocked() {  
        // TODO 계정이 잠겨있는 지 여부 체크  
        // = 계정 잠금 기능이 있는 경우 구현  
        return true;  
    }  
  
    @Override  
    public boolean isCredentialsNonExpired() {  
        // TODO 구현 필요: 패스워드 만료 여부 체크  
        // = 패스워드 설정 기한이 존재하는 경우 구현  
        return true;  
    }  
  
    @Override  
    public boolean isEnabled() {  
        // TODO 구현 필요: 사용 가능한 활성화 계정 여부  
        // = 사용 가능 여부 체크 경우만 구현  
        return true;  
    }  
  
}

JwtTokenProvider

액세스/리퀘스트 토큰의 생성, 검증 등을 담당하는 객체이다. 암호화 키 값과 만료 시간 등을 지정하고, 각종 토큰 관련 작업을 담당하게끔 했다.

import io.jsonwebtoken.*;  
import org.springframework.stereotype.Component;  
  
import javax.servlet.http.HttpServletRequest;  
import java.nio.charset.StandardCharsets;  
import java.util.Base64;  
import java.util.Date;  
import java.util.HashMap;  
import java.util.Map;  
import java.util.logging.Logger;  
  
@Component  
public class JwtTokenProvider {  
  
    private final static Logger logger = Logger.getLogger(JwtTokenProvider.class.getName());  
  
    private final String accessTokenKey = "sample-access-key";  
  
    private final String refreshTokenKey = "sample-refresh-key";  
  
    private final int accessTokenExpirationTime = 900000; // 액세스 토큰의 유효 시간 (예: 15분)  
  
    private final int refreshTokenExpirationTime = 1800000; // 리프레시 토큰의 유효 시간 (예: 7일)  
  
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;  
  
    // 액세스 토큰 생성  
    public String generateAccessToken(CustomUserDetails userDetails) {  
        logger.info("━━━━━━━━━━━━━━━━━generateAccessToken START━☂☄");  
        logger.info("☆゚.*・。゚ 액세스 토큰 만료 시간 " + accessTokenExpirationTime + " milliseconds ᝯ◂ ࠫ‘֊‘ ࠫ▾ಎ➹");  
        Map<String, Object> claims = new HashMap<>();  
        claims.put("username", userDetails.getUsername());  
        claims.put("password", userDetails.getPassword());  
        return new String(Base64.getEncoder().encode(Jwts.builder()  
                .setClaims(claims)  
                .setIssuedAt(new Date())  
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirationTime))  
                .signWith(signatureAlgorithm, accessTokenKey)  
                .compact().getBytes(StandardCharsets.UTF_8)));  
    }  
  
    // 구 액세스 토큰으로 신규 액세스 토큰 발급  
    public String generateNewAccessToken(String accessToken) {  
        logger.info("━━━━━━━━━━━━━━━━━generateNewAccessToken START━☂☄");  
        logger.info("☆゚.*・。゚ 액세스 토큰 만료 시간 " + accessTokenExpirationTime + " milliseconds ᝯ◂ ࠫ‘֊‘ ࠫ▾ಎ➹");  
        Map<String, Object> claims = new HashMap<>();  
        claims.put("username", getUsernameFromToken(accessToken));  
        claims.put("password", getPasswordFromToken(accessToken));  
  
        return new String(Base64.getEncoder().encode(Jwts.builder()  
                .setClaims(claims)  
                .setIssuedAt(new Date())  
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirationTime))  
                .signWith(signatureAlgorithm, accessTokenKey)  
                .compact().getBytes(StandardCharsets.UTF_8)));  
    }  
  
    // 리프레시 토큰 생성  
    public String generateRefreshToken() {  
        logger.info("━━━━━━━━━━━━━━━━━generateRefreshToken START━☂☄");  
        return new String(Base64.getEncoder().encode(Jwts.builder()  
                .setClaims(null)  
                .setIssuedAt(new Date())  
                .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpirationTime))  
                .signWith(signatureAlgorithm, refreshTokenKey)  
                .compact().getBytes(StandardCharsets.UTF_8)));  
    }  
  
    // 토큰의 유효성 검증  
    public boolean validateAccessToken(String token) {  
        logger.info("━━━━━━━━━━━━━━━━━validateAccessToken START━☂☄");  
        try {  
            String decryptedToken = new String(Base64.getDecoder().decode(token.getBytes(StandardCharsets.UTF_8)));  
            Jws<Claims> claims = Jwts.parser()  
                    .setSigningKey(accessTokenKey)  
                    .parseClaimsJws(decryptedToken);  
  
            logger.info("Token Claims Expiration");  
            logger.info(claims.getBody().getExpiration().toString());  
            logger.info(new Date().toString());  
  
            logger.info("━━━━━━━━━━━━━━━━━validateAccessToken END━☂☄");  
            return !claims.getBody().getExpiration().before(new Date());  
        } catch (JwtException | IllegalArgumentException e) {  
            e.printStackTrace();  
            return false;  
        }  
  
    }  
  
    // 리프레시 토큰 유효성 검증  
    public boolean validateRefreshToken(String accessToken, String refreshToken) {  
        logger.info("━━━━━━━━━━━━━━━━━validateRefreshToken START━☂☄");  
        try {  
            String decryptedToken = new String(Base64.getDecoder().decode(refreshToken.getBytes(StandardCharsets.UTF_8)));  
            Jws<Claims> claims = Jwts.parser()  
                    .setSigningKey(refreshTokenKey)  
                    .parseClaimsJws(decryptedToken);  
  
            logger.info("Token Claims Expiration");  
            logger.info(claims.getBody().getExpiration().toString());  
            logger.info(new Date().toString());  
  
            logger.info("━━━━━━━━━━━━━━━━━validateRefreshToken END━☂☄");  
            return !claims.getBody().getExpiration().before(new Date());  
        } catch (JwtException | IllegalArgumentException e) {  
            e.printStackTrace();  
            return false;  
        }  
    }  
  
    public Claims extractClaims(String token) {  
        String baseDecryptedToken = new String(Base64.getDecoder().decode(token.getBytes(StandardCharsets.UTF_8)));  
        return Jwts.parser().setSigningKey(accessTokenKey).parseClaimsJws(baseDecryptedToken).getBody();  
    }  
  
    public String getUsernameFromToken(String token) {  
        Claims claims = extractClaims(token);  
        return claims.get("username", String.class);  
    }  
  
    public String getPasswordFromToken(String token) {  
        Claims claims = extractClaims(token);  
        return claims.get("password", String.class);  
    }  
  
    // 요청 헤더에서 JWT 토큰을 추출하는 메소드입니다.  
    public String extractToken(HttpServletRequest request) {  
        String bearerToken = request.getHeader("Authorization");  
        // "Authorization" 헤더가 "Bearer "로 시작하는 경우, 토큰 부분을 추출합니다.  
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {  
            return bearerToken.substring(7); // "Bearer " 이후의 문자열(토큰)을 반환합니다.  
        }  
        return null; // 조건에 맞지 않는 경우 null을 반환합니다.  
    }  
}

JwtAuthenticationFilter

OncePerRequestFilter를 확장하는 필터 클래스이다. 토큰에 대한 검사를 진행하며, 헤더에 토큰이 없는 경우 PASS하고 있는 경우, 검증한다.

중요한 점은, 해당 필터에서 다음 필터로 넘어갈 때(doFilter) 다른 필터를 태우지 않으려면 SecurityContext 에 인증 정보를 저장해서 인증 처리를 해주어야 한다는 점이다.

import org.slf4j.Logger;  
import org.slf4j.LoggerFactory;  
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;  
import org.springframework.security.core.Authentication;  
import org.springframework.security.core.context.SecurityContextHolder;  
import org.springframework.web.filter.OncePerRequestFilter;  
  
import javax.servlet.FilterChain;  
import javax.servlet.ServletException;  
import javax.servlet.http.HttpServletRequest;  
import javax.servlet.http.HttpServletResponse;  
import java.io.IOException;  
  
public class JwtAuthenticationFilter extends OncePerRequestFilter {  
  
    private final JwtTokenProvider jwtTokenProvider;  
  
    // 로깅을 위한 SLF4J Logger 인스턴스를 생성합니다.  
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);  
  
    // 생성자를 통해 JwtTokenProvider 의존성 주입  
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {  
        this.jwtTokenProvider = jwtTokenProvider;  
    }  
  
    // 필터링 로직을 구현합니다. 모든 요청에 대해 이 메소드가 호출됩니다.  
    @Override  
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)  
            throws IOException, ServletException {  
        logger.info("━━━━━━━━━━━━━━━━━JwtAuthenticationFilter START━☂☄");  
        // 요청에서 JWT 토큰을 추출합니다.  
        String token = jwtTokenProvider.extractToken(request);  
        logger.info("Extracted Token : {}", token);  
  
        // 토큰이 null이 아니라면 유효성 검사를 수행합니다.  
        if (token != null) {  
            try {  
                if (jwtTokenProvider.validateAccessToken(token)) { // 토큰이 유효한 경우  
                    // 토큰으로부터 사용자 정보를 추출합니다.  
                    String username = jwtTokenProvider.getUsernameFromToken(token);  
                    String password = jwtTokenProvider.getPasswordFromToken(token);  
  
                    logger.info("from Token Info username  : {} ", username);  
                    logger.info("from Token Info password  : {} ", password);  
  
                    // 인증 정보를 만듭니다. 여기서는 권한(authorities) 리스트를 비워둡니다.  
                    Authentication authentication = new UsernamePasswordAuthenticationToken(username, password, null);  
  
                    // SecurityContext에 인증 정보를 설정합니다.  
                    SecurityContextHolder.getContext().setAuthentication(authentication);  
                    logger.info("☆゚.*・。゚ 토큰 유효성 검사를 통과하였습니다! ᝯ◂ ࠫ‘֊‘ ࠫ▾ಎ➹");  
                } else { // 액세스 토큰이 유효하지 않은 경우  
                    // 유효하지 않은 토큰인 경우 401 Unauthorized 응답 설정  
                    throw new Exception("유효하지 않은 토큰입니다.");  
                }  
            } catch (Exception e) {  
                e.printStackTrace();  
            }  
        }  
        logger.info("━━━━━━━━━━━━━━━━━JwtAuthenticationFilter END━☂☄");  
        // 현재 필터 이후에 정의된 필터들에 대한 처리를 계속 진행합니다.  
        chain.doFilter(request, response);  
    }  
}

SecurityConfig

위에서 작업한 클래스들을 시큐리티 설정에 추가합니다.

import lombok.RequiredArgsConstructor;  
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.security.config.Customizer;  
import org.springframework.security.config.annotation.web.builders.HttpSecurity;  
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;  
import org.springframework.security.crypto.password.NoOpPasswordEncoder;  
import org.springframework.security.crypto.password.PasswordEncoder;  
import org.springframework.security.web.SecurityFilterChain;  
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;  
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;  
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;  
  
@Configuration  
@RequiredArgsConstructor  
@EnableWebSecurity(debug = true)  
public class SecurityConfig {  
  
    private final JwtTokenProvider jwtTokenProvider;  
  
    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
        return http  
                .csrf(csrf -> csrf  
                        // h2 콘솔 경로 미적용  
                        .ignoringAntMatchers("/h2-console/**")  
                        // 토큰 발급 URL은 CSRF 미적용  
                        .ignoringAntMatchers("/getToken")  
                        .ignoringAntMatchers("/refreshAccessToken")  
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())  
                )  
                // h2 사용을 위한 헤더 설정  
                .headers().frameOptions().disable()  
                .and()  
                .cors().disable()  
                .formLogin().disable() // 폼 로그인 비활성화  
                .httpBasic(Customizer.withDefaults()) // Http 요청에 대한 설정  
                .authorizeHttpRequests(authz -> authz  
                        // 토큰 발급 구간은 인증 체크 안함.  
                        .requestMatchers(new AntPathRequestMatcher("/getToken")).permitAll()  
                        .requestMatchers(new AntPathRequestMatcher("/refreshAccessToken")).permitAll()  
                        .requestMatchers(PathRequest.toH2Console()).permitAll()  
                        .anyRequest().authenticated()  
                )  
                .and()  
                // JWT Token 인증 필터를 Http Basic 요청 처리 필터인 BasicAuthenticationFilter 전으로 등록.  
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), BasicAuthenticationFilter.class)  
                .build();  
    }  
  
    // 비밀번호 인코딩을 위해 NoOpPasswordEncoder를 사용하는 PasswordEncoder 빈을 등록  
    // 경고: 실제 환경에서는 사용하지 마세요!  
    @SuppressWarnings("deprecation")  
    @Bean  
    public PasswordEncoder passwordEncoder() {  
        return NoOpPasswordEncoder.getInstance();  
    }  
}

테스트

  • 토큰 발급 테스트 Pasted image 20240404022548.png
  • 리프레시 토큰으로 갱신 테스트 Pasted image 20240404022611.png

    결론

    이렇게 해서 간단한 JWT Token 인증을 구현해 보았습니다.

구현하면서 알게 된 점은 다음과 같습니다.

  1. 토큰 인증 외 다른 인증(세션 등)은 사용하지 않기 때문에 비활성화 하고, 다른 필터 또한 거치지 않게 해야 한다는 점.
  2. 모든 요청을 검사해야 하기 때문에 부하가 있을 수 있어 OncePerRequestFilter를 확장해 구현했다는 점.
  3. 토큰 탈취를 100% 막을 수 없어 그에 대한 대안인 리프레시 토큰을 사용한다는 것.
    • 추가적으로 토큰을 레디스에 저장하고 이를 검증하는 방법도 있다고 합니다.

생각보다 스프링 시큐리티가 필터 체인 형태로 내가 원하는 필터를 구현해서 사이에 껴넣거나 제공 필터를 확장하거나 구현해서 대체를 하면 그냥 원하는 대로 슝슝 작동할 줄 알았는데 생각보다 어려웠습니다.

또한, 구글링을 통해 자료를 찾아보면 공식 문서는 역할과 프로세스 설명은 자세하나 예시가 많이 없고 간단하여 참고하기 어려웠고 다른 분들이 구현한 자료들을 전부 중구난방으로 구현되어 있고 동일한 기능이어도 각자 구현한 위치가 달라 참고가 어려웠습니다.

너무 추상화되어 있고 열려있어서 발생하는 문제가 아닐까 .. 사실 개념을 이해하고, 순서와 동작을 이해하게되면 크게 어렵지는 않은데 거기까지 도달하는 과정에서의 삽질은 필연적으로 발생하는 것 같습니다.

위 소스는 아래 링크에서 보실 수 있습니다.

https://github.com/Chiptune93/springboot.java.example/tree/feature/spring-security/jwt-token


© 2024. Chiptune93 All rights reserved.