JWT 완벽 마스터

JWT의 핵심 개념과 실전 활용법

JavaScript중급
12시간
6개 항목
학습 진행률0 / 6 (0%)

학습 항목

1. Java
Spring|Security|JWT|인증|구현
퀴즈튜토리얼
2. JavaScript
고급
JWT 최신 기능 완벽 가이드
퀴즈튜토리얼
3. JavaScript
중급
JWT|최신|기능|완벽|가이드
퀴즈튜토리얼
4. JavaScript
OAuth|2.0|완벽|가이드
퀴즈튜토리얼
5. JavaScript
초급
OAuth|2.1|최신|기능|완벽|가이드
퀴즈튜토리얼
6. TypeScript
고급
JWT|테스트|전략|완벽|가이드
퀴즈튜토리얼
1 / 6

이미지 로딩 중...

Spring Security JWT 인증 구현 - 슬라이드 1/11

Spring Security JWT 인증 완벽 가이드

Spring Boot 애플리케이션에서 JWT 토큰 기반 인증을 구현하는 방법을 단계별로 알아봅니다. Security Filter Chain 설정부터 토큰 생성, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.


목차

  1. JWT란 무엇인가 - 토큰 기반 인증의 핵심 개념
  2. Spring Security 의존성 설정 - JWT 구현을 위한 기본 환경
  3. JWT 토큰 생성 유틸리티 - 사용자 정보를 토큰으로 변환
  4. JWT 토큰 검증 로직 - 요청마다 토큰의 유효성 확인
  5. JWT 인증 필터 구현 - 매 요청마다 토큰 검증하기
  6. Security Configuration 설정 - Filter Chain과 보안 정책 구성
  7. 로그인 API 구현 - 사용자 인증 후 토큰 발급
  8. UserDetailsService 구현 - 사용자 정보 로딩
  9. PasswordEncoder 설정 - 비밀번호 안전하게 저장하기
  10. Refresh Token 구현 - Access Token 갱신 메커니즘

1. JWT란 무엇인가 - 토큰 기반 인증의 핵심 개념

시작하며

여러분이 웹 애플리케이션을 개발하면서 "로그인한 사용자 정보를 어떻게 계속 유지하지?"라는 고민을 해본 적 있나요? 전통적인 세션 방식은 서버에 사용자 정보를 저장해야 하고, 서버가 여러 대일 경우 세션 공유 문제도 발생합니다.

특히 마이크로서비스 아키텍처나 모바일 앱 개발 시 이런 문제는 더욱 복잡해집니다. 서버 확장성이 떨어지고, CORS 이슈도 발생하며, 모바일 앱에서는 쿠키 관리가 까다롭습니다.

바로 이럴 때 필요한 것이 JWT(JSON Web Token)입니다. JWT를 사용하면 서버가 상태를 유지하지 않아도 되고(Stateless), 토큰 자체에 사용자 정보가 담겨있어 확장성이 뛰어납니다.

개요

간단히 말해서, JWT는 JSON 형태의 정보를 안전하게 전송하기 위한 토큰입니다. 세 부분(Header, Payload, Signature)으로 구성되며, 점(.)으로 구분됩니다.

왜 JWT가 필요할까요? 서버가 여러 대로 확장될 때 세션 동기화 문제가 없고, 모바일 앱에서도 쉽게 사용할 수 있으며, RESTful API 설계 원칙에도 잘 맞습니다.

예를 들어, Netflix나 Spotify 같은 대규모 서비스에서 수백만 명의 사용자를 처리할 때 매우 유용합니다. 기존 세션 방식에서는 서버 메모리에 사용자 정보를 저장했다면, JWT를 사용하면 토큰 자체에 정보가 담겨있어 서버는 단순히 검증만 하면 됩니다.

JWT의 핵심 특징은 크게 세 가지입니다. 첫째, 자가 수용적(Self-contained)이어서 토큰 자체에 필요한 정보가 모두 담겨있습니다.

둘째, 디지털 서명되어 있어 위조가 불가능합니다. 셋째, URL-safe한 문자열이라 HTTP 헤더나 URL 파라미터로 쉽게 전달할 수 있습니다.

이러한 특징들이 마이크로서비스 환경에서 특히 중요합니다.

코드 예제

// JWT 토큰 구조 예시 (Base64 디코딩 전)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// 디코딩된 Header 부분
{
  "alg": "HS256",  // 서명 알고리즘
  "typ": "JWT"     // 토큰 타입
}

// 디코딩된 Payload 부분
{
  "sub": "1234567890",           // Subject: 사용자 식별자
  "name": "John Doe",            // 사용자 이름
  "iat": 1516239022,             // Issued At: 발급 시간
  "exp": 1516242622              // Expiration: 만료 시간
}

설명

JWT가 하는 일은 사용자 정보를 안전하게 포장하여 클라이언트와 서버 간에 전달하는 것입니다. 마치 봉인된 소포처럼, 내용물(Payload)을 담고, 라벨(Header)을 붙이고, 봉인(Signature)을 하는 구조입니다.

첫 번째로, Header 부분은 토큰의 메타데이터를 담습니다. 어떤 알고리즘으로 서명했는지(보통 HS256이나 RS256), 토큰 타입이 무엇인지를 명시합니다.

이 정보는 나중에 토큰을 검증할 때 어떤 방식으로 검증해야 하는지 알려줍니다. 그 다음으로, Payload 부분이 실행되면서 실제 전달하고 싶은 정보를 담습니다.

사용자 ID, 이름, 권한, 만료 시간 등 필요한 정보를 JSON 형태로 넣습니다. 단, 여기에는 비밀번호 같은 민감한 정보는 절대 넣으면 안 됩니다.

Base64로 인코딩되어 있을 뿐 암호화된 것이 아니기 때문입니다. 마지막으로, Signature 부분이 Header와 Payload를 합쳐서 비밀키로 서명합니다.

최종적으로 이 서명을 통해 토큰이 위조되지 않았음을 보장할 수 있습니다. 만약 누군가 Payload의 권한 정보를 "USER"에서 "ADMIN"으로 바꾸려 한다면, Signature가 일치하지 않아 즉시 탐지됩니다.

여러분이 JWT를 사용하면 서버 확장이 자유롭고, 다른 도메인 간 인증도 쉽게 처리할 수 있습니다. 또한 모바일 앱, 웹, API 서버 등 다양한 클라이언트에서 동일한 방식으로 인증을 구현할 수 있어 개발 생산성이 크게 향상됩니다.

Netflix처럼 글로벌 서비스를 운영할 때 각 지역의 서버가 독립적으로 토큰을 검증할 수 있다는 것은 엄청난 장점입니다.

실전 팁

💡 Payload에는 절대 비밀번호, 신용카드 정보 등 민감한 데이터를 넣지 마세요. JWT는 암호화가 아닌 서명만 되어있어 누구나 내용을 볼 수 있습니다.

💡 토큰 만료 시간은 보안과 사용성의 균형을 맞춰야 합니다. Access Token은 15분1시간, Refresh Token은 2주1개월 정도가 적절합니다.

💡 jwt.io 웹사이트를 활용하면 토큰을 쉽게 디코딩하고 검증할 수 있어 디버깅 시 매우 유용합니다.

💡 Payload 크기를 최소화하세요. 매 요청마다 토큰이 전송되므로, 불필요한 정보가 많으면 네트워크 트래픽이 증가합니다.

💡 알고리즘은 HS256(HMAC SHA256) 또는 RS256(RSA SHA256)을 사용하세요. 'none' 알고리즘은 보안 취약점이 있어 절대 사용하면 안 됩니다.


2. Spring Security 의존성 설정 - JWT 구현을 위한 기본 환경

시작하며

여러분이 Spring Boot 프로젝트에서 JWT 인증을 구현하려고 할 때, "어떤 라이브러리를 사용해야 하지?"라는 궁금증이 생깁니다. Spring Security는 강력하지만, JWT 기능은 기본 제공되지 않아 추가 라이브러리가 필요합니다.

많은 개발자들이 처음에 이 부분에서 헤매곤 합니다. 어떤 JWT 라이브러리를 선택해야 할지, 버전 호환성은 어떻게 맞춰야 할지, 의존성 충돌은 어떻게 해결할지 등의 문제가 발생합니다.

바로 이럴 때 필요한 것이 올바른 의존성 설정입니다. jjwt 라이브러리를 사용하면 JWT 생성, 파싱, 검증을 간편하게 처리할 수 있습니다.

개요

간단히 말해서, Spring Security와 JWT를 함께 사용하려면 세 가지 핵심 의존성이 필요합니다. Spring Security Starter, jjwt-api, jjwt-impl, jjwt-jackson이 그것입니다.

왜 이 의존성들이 필요할까요? Spring Security Starter는 인증/인가 프레임워크의 기본을 제공하고, jjwt 라이브러리는 JWT 토큰의 생성과 검증 기능을 제공합니다.

예를 들어, 로그인 성공 시 토큰을 발급하고, 매 요청마다 토큰을 검증하는 전체 플로우를 구현할 수 있습니다. 기존에는 토큰을 직접 파싱하고 서명을 검증하는 복잡한 코드를 작성해야 했다면, jjwt 라이브러리를 사용하면 몇 줄의 코드로 안전하게 처리할 수 있습니다.

의존성 설정의 핵심 포인트는 세 가지입니다. 첫째, jjwt-api는 컴파일 타임에, jjwt-impl과 jjwt-jackson은 런타임에 필요합니다.

둘째, 버전을 명시적으로 관리해야 호환성 문제를 피할 수 있습니다. 셋째, Spring Boot 버전과 Spring Security 버전의 호환성도 확인해야 합니다.

이러한 설정이 제대로 되어야 이후 JWT 구현이 순조롭게 진행됩니다.

코드 예제

<!-- pom.xml - Maven 의존성 설정 -->
<dependencies>
    <!-- Spring Security 기본 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JWT 라이브러리 - API (컴파일 타임) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>
    </dependency>

    <!-- JWT 구현체 (런타임) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>

    <!-- JSON 처리를 위한 Jackson 바인딩 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

설명

이 의존성 설정이 하는 일은 JWT 토큰 기반 인증 시스템을 구축하기 위한 기반을 마련하는 것입니다. 마치 집을 짓기 전에 필요한 건축 자재를 준비하는 것과 같습니다.

첫 번째로, spring-boot-starter-security는 Spring Security의 모든 핵심 기능을 제공합니다. Filter Chain, Authentication Manager, UserDetailsService 등 인증 시스템의 뼈대를 구성합니다.

이것이 없다면 사용자 인증 자체를 처리할 수 없습니다. 그 다음으로, jjwt-api가 실행되면서 JWT 토큰을 다루는 인터페이스를 제공합니다.

이는 컴파일 타임에 필요한 클래스들로, 여러분의 코드에서 직접 import해서 사용하게 됩니다. Jwts 클래스를 통해 토큰을 생성하고 파싱하는 메서드에 접근할 수 있습니다.

마지막으로, jjwt-impl과 jjwt-jackson이 런타임에 실제 구현체를 제공합니다. jjwt-impl은 토큰 생성과 검증의 실제 로직을 담고 있고, jjwt-jackson은 JSON과 Java 객체 간 변환을 처리합니다.

최종적으로 이 세 개의 jjwt 라이브러리가 협력하여 완전한 JWT 기능을 제공합니다. 여러분이 이 의존성들을 제대로 설정하면 타입 안정성을 보장받고, 최신 보안 패치를 적용받으며, 풍부한 API를 활용할 수 있습니다.

또한 jjwt는 Java 8 이상의 최신 기능을 활용하여 설계되었기 때문에, 람다 표현식이나 Stream API와도 잘 어울립니다. 실무에서는 이러한 의존성 관리가 프로젝트의 안정성과 유지보수성을 크게 좌우합니다.

실전 팁

💡 Gradle을 사용한다면 implementation 'io.jsonwebtoken:jjwt-api:0.12.3'와 runtimeOnly로 impl, jackson을 추가하세요.

💡 jjwt 버전은 가능한 최신 버전을 사용하세요. 0.12.x 버전은 보안 개선과 새로운 알고리즘을 지원합니다.

💡 Spring Boot 3.x를 사용한다면 Spring Security 6.x가 자동으로 설정되므로, 설정 방식이 이전 버전과 다를 수 있습니다.

💡 의존성 버전 충돌 시 ./mvnw dependency:tree 명령으로 의존성 트리를 확인하여 문제를 진단하세요.

💡 개발 환경에서는 spring-boot-devtools도 함께 추가하면 코드 변경 시 자동 재시작되어 생산성이 향상됩니다.


3. JWT 토큰 생성 유틸리티 - 사용자 정보를 토큰으로 변환

시작하며

여러분이 사용자가 로그인에 성공했을 때, "이제 이 사용자 정보를 어떻게 토큰으로 만들지?"라는 단계에 도달합니다. 단순히 사용자 ID를 문자열로 만드는 것이 아니라, 만료 시간도 설정하고, 권한 정보도 넣고, 안전하게 서명까지 해야 합니다.

이 과정에서 많은 실수가 발생합니다. 비밀키를 하드코딩하거나, 만료 시간을 제대로 설정하지 않거나, 클레임(Claim) 이름을 잘못 사용하는 경우가 많습니다.

바로 이럴 때 필요한 것이 체계적인 JWT 생성 유틸리티입니다. 재사용 가능하고, 안전하며, 테스트하기 쉬운 구조로 만들어야 합니다.

개요

간단히 말해서, JWT 토큰 생성 유틸리티는 사용자 정보와 설정값을 받아서 서명된 JWT 문자열을 반환하는 컴포넌트입니다. 왜 별도의 유틸리티 클래스가 필요할까요?

토큰 생성 로직이 여러 곳에 흩어지면 유지보수가 어렵고, 보안 정책을 일관되게 적용하기 힘듭니다. 예를 들어, 로그인 API, 토큰 갱신 API, 소셜 로그인 등 여러 지점에서 토큰을 발급하는데, 모두 동일한 방식을 사용해야 합니다.

기존에는 토큰 생성 코드를 각 컨트롤러에 직접 작성했다면, 이제는 JwtTokenProvider 같은 유틸리티 클래스로 중앙화할 수 있습니다. 토큰 생성의 핵심 요소는 네 가지입니다.

첫째, Secret Key는 환경변수나 설정 파일로 관리하여 코드에 노출되지 않게 합니다. 둘째, Subject에는 사용자 식별자(보통 username 또는 user ID)를 넣습니다.

셋째, Issued At과 Expiration으로 토큰의 유효 기간을 명확히 합니다. 넷째, 필요한 경우 Custom Claims로 권한이나 추가 정보를 담습니다.

이러한 요소들이 제대로 설정되어야 안전하고 효율적인 토큰이 만들어집니다.

코드 예제

@Component
public class JwtTokenProvider {

    // 비밀키는 환경변수나 application.yml에서 주입받습니다
    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long validityInMilliseconds; // 예: 3600000 (1시간)

    // 비밀키를 Key 객체로 변환 (초기화 시 한 번만 실행)
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 사용자 정보로 토큰 생성
    public String generateToken(String username, List<String> roles) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setSubject(username)                    // 사용자 식별자
                .claim("roles", roles)                   // 권한 정보
                .setIssuedAt(now)                        // 발급 시간
                .setExpiration(expiryDate)               // 만료 시간
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)  // 서명
                .compact();
    }
}

설명

이 토큰 생성 유틸리티가 하는 일은 사용자의 인증 정보를 안전하게 포장하여 클라이언트에게 전달할 수 있는 형태로 만드는 것입니다. 마치 공항에서 수하물에 태그를 붙이고 봉인하는 것과 비슷합니다.

첫 번째로, getSigningKey() 메서드는 문자열 형태의 비밀키를 실제 암호화에 사용할 수 있는 Key 객체로 변환합니다. secretKey는 Base64로 인코딩된 문자열인데, 이를 디코딩한 후 HMAC SHA256 알고리즘에 맞는 키로 변환합니다.

이 키는 나중에 토큰 서명과 검증 모두에 사용됩니다. 그 다음으로, generateToken() 메서드가 실행되면서 실제 토큰을 만들어냅니다.

먼저 현재 시간과 만료 시간을 계산합니다. 그리고 Jwts.builder()를 사용하여 빌더 패턴으로 토큰을 구성합니다.

setSubject()로 사용자 이름을 넣고, claim()으로 권한 정보를 추가합니다. roles는 배열이나 리스트 형태로 들어가며, 나중에 토큰 검증 시 사용자의 권한을 확인할 때 사용됩니다.

마지막으로, signWith()로 토큰에 서명을 합니다. HS256 알고리즘과 앞서 생성한 비밀키를 사용하여 Header와 Payload를 합친 값의 서명을 생성합니다.

최종적으로 compact()를 호출하면 "xxx.yyy.zzz" 형태의 JWT 문자열이 반환됩니다. 여러분이 이 유틸리티를 사용하면 토큰 생성 로직이 한 곳에 집중되어 유지보수가 쉽고, @Component로 등록되어 있어 어디서든 주입받아 사용할 수 있습니다.

또한 설정값을 외부화했기 때문에 개발/스테이징/프로덕션 환경마다 다른 비밀키와 만료 시간을 사용할 수 있어 보안성과 유연성이 모두 확보됩니다. 실무에서는 이렇게 토큰 생성 로직을 캡슐화하는 것이 필수적입니다.

실전 팁

💡 비밀키는 최소 256비트(32바이트) 이상이어야 HS256 알고리즘에서 안전합니다. Keys.secretKeyFor(SignatureAlgorithm.HS256)로 생성할 수 있습니다.

💡 만료 시간은 보안과 UX의 균형이 중요합니다. Access Token은 짧게(15분), Refresh Token은 길게(2주) 설정하는 이중 토큰 전략을 권장합니다.

💡 roles 같은 custom claim 이름은 표준 claim(iss, sub, exp 등)과 겹치지 않게 주의하세요. 필요하다면 네임스페이스를 사용하세요.

💡 토큰 생성 시점에 로그를 남기면 추후 보안 감사나 문제 추적 시 매우 유용합니다. 단, 토큰 전체를 로깅하지는 마세요.

💡 개발 환경에서는 jwt.io에서 토큰을 디코딩하여 Payload가 의도대로 들어갔는지 확인하는 습관을 들이세요.


4. JWT 토큰 검증 로직 - 요청마다 토큰의 유효성 확인

시작하며

여러분이 클라이언트로부터 토큰을 받았을 때, "이 토큰이 정말 우리가 발급한 것이 맞나? 만료되지는 않았나?"를 확인해야 합니다.

단순히 토큰이 존재한다고 신뢰하면 보안 사고로 이어질 수 있습니다. 실무에서 많은 보안 취약점이 토큰 검증 단계에서 발생합니다.

서명을 검증하지 않거나, 만료 시간을 무시하거나, 예외 처리를 제대로 하지 않는 경우가 많습니다. 바로 이럴 때 필요한 것이 철저한 토큰 검증 로직입니다.

서명 검증, 만료 확인, 형식 체크를 모두 수행해야 합니다.

개요

간단히 말해서, 토큰 검증은 받은 JWT가 위조되지 않았고, 아직 유효한지 확인하는 과정입니다. jjwt 라이브러리의 JwtParser를 사용하면 이 모든 것이 자동으로 처리됩니다.

왜 검증이 중요할까요? 공격자가 Payload를 조작하여 권한을 상승시키려 할 수 있고, 만료된 토큰을 재사용하려 할 수도 있습니다.

예를 들어, 일반 사용자가 토큰의 roles를 "ADMIN"으로 바꾸려 시도할 수 있지만, 서명 검증에서 걸러집니다. 기존에는 수동으로 서명을 계산하고 비교하는 복잡한 코드를 작성해야 했다면, jjwt를 사용하면 parseClaimsJws() 메서드 하나로 모든 검증이 자동 수행됩니다.

토큰 검증의 핵심 단계는 네 가지입니다. 첫째, 토큰 형식 확인(세 부분으로 나뉘어 있는지).

둘째, 서명 검증(비밀키로 재계산한 서명과 일치하는지). 셋째, 만료 시간 확인(현재 시간이 exp보다 이전인지).

넷째, Claims 추출(검증된 토큰에서 사용자 정보 꺼내기). 이 단계들이 모두 통과해야 토큰을 신뢰할 수 있습니다.

코드 예제

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // 토큰에서 사용자 이름 추출
    public String getUsername(String token) {
        return getClaims(token).getSubject();
    }

    // 토큰에서 권한 정보 추출
    public List<String> getRoles(String token) {
        return getClaims(token).get("roles", List.class);
    }

    // 토큰 파싱 및 Claims 추출 (검증도 자동 수행)
    private Claims getClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())          // 검증에 사용할 키 설정
                .build()
                .parseClaimsJws(token)                   // 파싱 및 서명 검증
                .getBody();                              // Claims 반환
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            getClaims(token);  // 파싱 시도 - 실패하면 예외 발생
            return true;
        } catch (ExpiredJwtException e) {
            // 만료된 토큰
            return false;
        } catch (JwtException | IllegalArgumentException e) {
            // 잘못된 토큰
            return false;
        }
    }
}

설명

이 검증 로직이 하는 일은 받은 토큰이 진짜인지, 유효한지를 철저히 확인하는 것입니다. 마치 공항 보안 검색대에서 여권의 위조 여부와 유효 기간을 확인하는 것과 같습니다.

첫 번째로, getClaims() 메서드는 토큰 검증의 핵심입니다. parserBuilder()로 파서를 생성하고, setSigningKey()로 검증에 사용할 비밀키를 설정합니다.

이 키는 토큰 생성 시 사용한 키와 동일해야 합니다. 그리고 parseClaimsJws()를 호출하면 자동으로 서명 검증, 만료 확인, 형식 체크가 모두 수행됩니다.

만약 토큰이 위조되었거나 만료되었다면 여기서 예외가 발생합니다. 그 다음으로, getUsername()과 getRoles() 메서드가 실행되면서 검증된 토큰에서 필요한 정보를 추출합니다.

getSubject()로 사용자 이름을 가져오고, get("roles", List.class)로 권한 목록을 가져옵니다. 이 메서드들은 내부적으로 getClaims()를 호출하므로, 검증되지 않은 토큰에서는 정보를 꺼낼 수 없습니다.

마지막으로, validateToken() 메서드가 토큰의 유효성만 간단히 확인합니다. try-catch로 getClaims()를 호출하여, 성공하면 true, 실패하면 false를 반환합니다.

최종적으로 ExpiredJwtException과 JwtException을 구분하여 처리할 수 있어, 만료된 토큰과 잘못된 토큰에 대해 다른 응답을 줄 수도 있습니다. 여러분이 이 검증 로직을 사용하면 서명 위조 시도를 100% 차단할 수 있고, 만료된 토큰의 재사용을 방지하며, 잘못된 형식의 토큰도 거를 수 있습니다.

또한 jjwt 라이브러리가 타이밍 공격 같은 고급 보안 위협도 내부적으로 방어하기 때문에, 직접 구현하는 것보다 훨씬 안전합니다. 실무에서는 이렇게 검증된 라이브러리를 사용하는 것이 보안의 기본입니다.

실전 팁

💡 예외 처리를 세분화하면 더 나은 사용자 경험을 제공할 수 있습니다. 만료된 토큰은 401과 함께 "토큰이 만료되었습니다" 메시지를, 잘못된 토큰은 "유효하지 않은 토큰입니다"를 반환하세요.

💡 토큰 검증 실패 시 로그를 남기되, 토큰 전체를 로깅하지는 마세요. 공격 시도를 분석할 수 있는 정보(IP, 시간, 예외 타입)만 기록하세요.

💡 성능을 위해 Claims 캐싱을 고려할 수 있지만, 보안을 위해 매 요청마다 재검증하는 것이 더 안전합니다.

💡 토큰 블랙리스트 기능이 필요하다면 Redis에 만료된 토큰을 저장하여 로그아웃 후 토큰 재사용을 막을 수 있습니다.

💡 테스트 시 Clock 인터페이스를 활용하면 만료 시간 테스트를 쉽게 할 수 있습니다. jjwt는 Clock 주입을 지원합니다.


5. JWT 인증 필터 구현 - 매 요청마다 토큰 검증하기

시작하며

여러분이 토큰 생성과 검증 로직을 만들었다면, 이제 "매 요청마다 어떻게 자동으로 토큰을 확인하지?"라는 질문에 답해야 합니다. 컨트롤러마다 토큰 검증 코드를 작성할 수는 없습니다.

많은 개발자들이 이 부분에서 Spring Security의 Filter Chain 개념을 이해하는 데 어려움을 겪습니다. 어떤 필터를 언제 실행해야 하는지, 기존 필터와 어떻게 연동하는지가 복잡합니다.

바로 이럴 때 필요한 것이 JWT 인증 필터입니다. OncePerRequestFilter를 상속받아 요청당 한 번만 실행되도록 보장하고, 토큰 검증 후 SecurityContext에 인증 정보를 저장합니다.

개요

간단히 말해서, JWT 인증 필터는 모든 HTTP 요청을 가로채서 Authorization 헤더의 토큰을 확인하고, 유효하면 인증된 사용자로 처리하는 컴포넌트입니다. 왜 필터가 필요할까요?

Spring Security는 Filter Chain 기반으로 동작하는데, 각 필터가 순차적으로 요청을 처리합니다. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 배치하면, 토큰 인증을 먼저 시도하고 실패 시 다른 인증 방식으로 넘어갈 수 있습니다.

예를 들어, API 요청은 토큰으로, 웹 로그인은 세션으로 처리하는 하이브리드 구성도 가능합니다. 기존 폼 로그인 방식에서는 UsernamePasswordAuthenticationFilter가 /login 요청만 처리했다면, JWT 필터는 모든 요청에서 토큰을 확인하여 stateless 인증을 구현합니다.

JWT 필터의 핵심 동작은 다섯 단계입니다. 첫째, Authorization 헤더에서 "Bearer {token}" 형식으로 토큰 추출.

둘째, 토큰이 있으면 유효성 검증. 셋째, 유효한 토큰에서 사용자 정보 추출.

넷째, UsernamePasswordAuthenticationToken 객체 생성. 다섯째, SecurityContextHolder에 인증 정보 저장.

이 과정이 완료되면 이후 컨트롤러에서 @AuthenticationPrincipal로 사용자 정보를 바로 사용할 수 있습니다.

코드 예제

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) throws ServletException, IOException {
        // 1. Authorization 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰이 있고 유효하면 인증 정보 설정
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsername(token);
            List<String> roles = jwtTokenProvider.getRoles(token);

            // 3. 권한 정보를 GrantedAuthority로 변환
            List<GrantedAuthority> authorities = roles.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

            // 4. Authentication 객체 생성
            Authentication auth = new UsernamePasswordAuthenticationToken(
                    username, null, authorities);

            // 5. SecurityContext에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 6. 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    // Authorization 헤더에서 Bearer 토큰 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);  // "Bearer " 이후의 토큰만 반환
        }
        return null;
    }
}

설명

이 필터가 하는 일은 모든 HTTP 요청의 관문에서 경비원 역할을 하는 것입니다. 마치 건물 입구에서 출입증을 확인하고, 유효하면 통과시키는 것과 같습니다.

첫 번째로, doFilterInternal() 메서드는 요청당 정확히 한 번만 실행됩니다. OncePerRequestFilter가 이를 보장하므로, forward나 redirect 시에도 중복 실행되지 않습니다.

먼저 resolveToken()을 호출하여 Authorization 헤더에서 "Bearer " 접두사를 제거하고 순수 토큰만 추출합니다. 헤더가 없거나 형식이 잘못되면 null을 반환하여 인증을 건너뜁니다.

그 다음으로, 토큰이 존재하고 유효성 검증을 통과하면 사용자 정보 추출 과정이 실행됩니다. jwtTokenProvider에서 username과 roles를 가져온 후, Spring Security가 이해할 수 있는 형식으로 변환합니다.

roles는 문자열 리스트인데, 이를 GrantedAuthority 인터페이스를 구현한 SimpleGrantedAuthority 객체로 변환합니다. 예를 들어 ["ROLE_USER", "ROLE_ADMIN"]은 두 개의 SimpleGrantedAuthority 객체가 됩니다.

마지막으로, UsernamePasswordAuthenticationToken을 생성하여 SecurityContextHolder에 저장합니다. 이 객체의 첫 번째 인자는 principal(사용자 식별자), 두 번째는 credentials(보통 null, JWT 방식에서는 비밀번호가 불필요), 세 번째는 authorities(권한 목록)입니다.

최종적으로 SecurityContextHolder.getContext().setAuthentication()을 호출하면, 이후 모든 Spring Security 컴포넌트가 이 인증 정보를 사용할 수 있게 됩니다. 여러분이 이 필터를 사용하면 컨트롤러에서 @PreAuthorize("hasRole('ADMIN')")처럼 권한 기반 접근 제어를 할 수 있고, @AuthenticationPrincipal로 현재 사용자 정보를 주입받을 수 있습니다.

또한 filterChain.doFilter()를 호출하여 다음 필터로 요청을 전달하므로, Spring Security의 다른 보안 기능(CSRF, CORS 등)도 정상적으로 동작합니다. 실무에서는 이렇게 필터 체인에 커스텀 필터를 끼워 넣어 인증 방식을 확장하는 것이 일반적입니다.

실전 팁

💡 필터에서 예외가 발생하면 Spring의 @ExceptionHandler가 처리하지 못합니다. 대신 HandlerExceptionResolver를 주입받아 예외를 처리하세요.

💡 특정 경로는 JWT 검증을 건너뛰고 싶다면 shouldNotFilter() 메서드를 오버라이드하여 /api/auth/login 같은 경로를 제외하세요.

💡 로그인하지 않은 사용자도 접근 가능한 public API가 있다면, 토큰이 없어도 필터를 통과시키고 컨트롤러에서 권한을 체크하세요.

💡 성능 최적화를 위해 정적 리소스(/css, /js, /images)는 필터 체인에서 아예 제외하는 것이 좋습니다.

💡 필터 순서가 중요합니다. addFilterBefore()로 UsernamePasswordAuthenticationFilter 앞에 배치하여 JWT 인증을 먼저 시도하세요.


6. Security Configuration 설정 - Filter Chain과 보안 정책 구성

시작하며

여러분이 JWT 필터를 만들었다면, 이제 "이 필터를 언제, 어디에 적용할 것인가?"를 설정해야 합니다. Spring Security의 기본 설정은 폼 로그인 기반이라, JWT 방식에는 맞지 않습니다.

많은 개발자들이 SecurityFilterChain 설정에서 헤매곤 합니다. CSRF를 비활성화해야 하는지, Session 정책은 어떻게 하는지, CORS는 어떻게 설정하는지 등 복잡한 결정을 내려야 합니다.

바로 이럴 때 필요한 것이 명확한 Security Configuration입니다. Stateless 방식에 맞는 설정으로, 세션을 사용하지 않고 매 요청마다 토큰으로 인증하도록 구성합니다.

개요

간단히 말해서, Security Configuration은 어떤 URL을 보호할지, 어떤 인증 방식을 사용할지, 필터 순서는 어떻게 할지를 정의하는 설정 클래스입니다. 왜 별도의 설정이 필요할까요?

Spring Security는 기본적으로 모든 요청을 보호하고, 폼 로그인과 세션을 사용합니다. 하지만 JWT는 stateless 방식이므로, 세션을 비활성화하고, CSRF도 꺼야 하며, 커스텀 필터를 추가해야 합니다.

예를 들어, REST API 서버라면 폼 로그인 페이지가 필요 없고, 모든 인증이 토큰으로 이루어져야 합니다. 기존 설정에서는 HttpSecurity에서 formLogin()과 sessionManagement()를 기본값으로 사용했다면, JWT 방식에서는 sessionManagement().sessionCreationPolicy(STATELESS)로 세션을 완전히 끕니다.

Security Configuration의 핵심 설정은 다섯 가지입니다. 첫째, CSRF 비활성화(토큰 기반 인증에서는 불필요).

둘째, Session 정책을 STATELESS로 설정. 셋째, 특정 URL은 인증 없이 접근 허용(로그인, 회원가입 등).

넷째, 나머지 모든 요청은 인증 필요. 다섯째, JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가.

이 설정들이 조화롭게 동작해야 안전하고 효율적인 API가 완성됩니다.

코드 예제

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 비활성화 (JWT 사용 시 불필요)
            .csrf(csrf -> csrf.disable())

            // Session 정책을 STATELESS로 설정
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // URL별 접근 권한 설정
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 로그인, 회원가입은 인증 불필요
                .requestMatchers("/api/admin/**").hasRole("ADMIN")  // 관리자 전용
                .anyRequest().authenticated()  // 나머지는 인증 필요
            )

            // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
            .addFilterBefore(jwtAuthenticationFilter,
                           UsernamePasswordAuthenticationFilter.class)

            // 폼 로그인 비활성화
            .formLogin(form -> form.disable())

            // HTTP Basic 인증 비활성화
            .httpBasic(basic -> basic.disable());

        return http.build();
    }

    // CORS 설정 (필요시)
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

설명

이 설정 클래스가 하는 일은 Spring Security의 전체 보안 정책을 JWT 방식에 맞게 재구성하는 것입니다. 마치 건물의 보안 시스템을 출입카드 방식으로 전환하는 것과 같습니다.

첫 번째로, csrf().disable()과 sessionManagement()는 stateless 환경을 만듭니다. CSRF(Cross-Site Request Forgery) 공격은 세션 기반 인증에서 발생하는데, JWT는 매 요청마다 토큰을 검증하므로 CSRF 토큰이 불필요합니다.

SessionCreationPolicy.STATELESS로 설정하면 Spring Security가 세션을 생성하지도, 사용하지도 않습니다. 이렇게 하면 서버가 상태를 유지하지 않아 수평 확장이 자유롭습니다.

그 다음으로, authorizeHttpRequests()가 실행되면서 URL별 접근 권한을 정의합니다. requestMatchers()로 패턴을 지정하고, permitAll()이나 hasRole()로 권한을 설정합니다.

예를 들어 "/api/auth/login"은 누구나 접근 가능하고, "/api/admin/**"은 ADMIN 역할이 있어야 하며, 나머지는 인증만 되면 접근 가능합니다. 이 순서가 중요한데, 구체적인 패턴을 먼저 작성하고 일반적인 패턴을 나중에 작성해야 합니다.

마지막으로, addFilterBefore()로 JWT 필터를 필터 체인에 삽입합니다. UsernamePasswordAuthenticationFilter 앞에 배치하여, 토큰 인증을 먼저 시도합니다.

최종적으로 formLogin()과 httpBasic()을 비활성화하여, 폼 로그인 페이지나 Basic 인증 팝업이 뜨지 않게 합니다. 여러분이 이 설정을 사용하면 전체 애플리케이션이 토큰 기반 인증으로 동작하고, 로그인 API만 public으로 열어두어 사용자가 토큰을 발급받을 수 있습니다.

또한 CORS 설정까지 포함하면 프론트엔드가 다른 도메인에 있어도 API 호출이 가능해집니다. 실무에서는 이렇게 모든 보안 정책을 한 곳에 모아 관리하는 것이 유지보수에 유리합니다.

실전 팁

💡 Spring Boot 3.0 이상에서는 WebSecurityConfigurerAdapter가 deprecated되었으므로, SecurityFilterChain 빈을 직접 등록하세요.

💡 개발 환경에서는 H2 콘솔이나 Swagger UI 접근을 위해 해당 경로를 permitAll()로 열어두세요.

💡 CORS 설정에서 setAllowedOrigins("*")는 보안상 위험하므로, 실제 프론트엔드 도메인만 명시하세요.

💡 hasRole()은 자동으로 "ROLE_" 접두사를 붙이므로, 토큰에는 "ROLE_ADMIN"으로 저장하고 설정에서는 hasRole("ADMIN")으로 사용하세요.

💡 ExceptionHandling을 추가하여 인증 실패 시 커스텀 에러 응답을 반환할 수 있습니다. authenticationEntryPoint()를 설정하세요.


7. 로그인 API 구현 - 사용자 인증 후 토큰 발급

시작하며

여러분이 모든 인프라를 구축했다면, 이제 "사용자가 실제로 어떻게 로그인하고 토큰을 받지?"라는 실질적인 API를 만들어야 합니다. 사용자 이름과 비밀번호를 받아서, 검증하고, JWT를 발급하는 엔드포인트가 필요합니다.

많은 개발자들이 이 부분에서 비밀번호 검증 로직을 어디에 둘지, 어떻게 암호화할지, 응답 형식은 어떻게 할지 고민합니다. 또한 로그인 실패 시 어떤 에러 메시지를 반환할지도 중요한 결정입니다.

바로 이럴 때 필요한 것이 명확한 로그인 API입니다. AuthenticationManager로 사용자를 인증하고, 성공 시 JWT를 발급하여 클라이언트에 반환합니다.

개요

간단히 말해서, 로그인 API는 사용자 인증 정보를 받아서 검증하고, 성공하면 JWT 토큰을 생성하여 반환하는 REST 엔드포인트입니다. 왜 AuthenticationManager를 사용할까요?

Spring Security의 핵심 인증 메커니즘을 활용하면, 비밀번호 암호화, 계정 잠금, 권한 로딩 등이 자동으로 처리됩니다. 직접 DB에서 사용자를 조회하고 비밀번호를 비교하는 것보다 훨씬 안전하고 확장 가능합니다.

예를 들어, 나중에 OAuth2 로그인을 추가하거나, 2FA(2단계 인증)를 도입할 때도 동일한 구조를 사용할 수 있습니다. 기존 폼 로그인 방식에서는 Spring Security가 자동으로 /login 엔드포인트를 만들었다면, JWT 방식에서는 커스텀 컨트롤러로 직접 구현하고 토큰을 반환합니다.

로그인 API의 핵심 단계는 네 가지입니다. 첫째, 요청에서 username과 password를 받습니다.

둘째, UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달합니다. 셋째, 인증 성공 시 사용자 정보로 JWT를 생성합니다.

넷째, 토큰을 JSON 형태로 응답합니다. 이 과정이 성공하면 클라이언트는 이 토큰을 저장하여 이후 요청에 사용합니다.

코드 예제

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private UserRepository userRepository;

    // 로그인 API
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        try {
            // 1. 인증 토큰 생성
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
                )
            );

            // 2. 인증 성공 - 사용자 정보 조회
            User user = userRepository.findByUsername(loginRequest.getUsername())
                    .orElseThrow(() -> new RuntimeException("User not found"));

            // 3. JWT 토큰 생성
            List<String> roles = user.getRoles().stream()
                    .map(role -> role.getName())
                    .collect(Collectors.toList());

            String token = jwtTokenProvider.generateToken(
                    user.getUsername(),
                    roles
            );

            // 4. 토큰 응답
            return ResponseEntity.ok(new JwtResponse(token));

        } catch (BadCredentialsException e) {
            // 인증 실패 - 잘못된 비밀번호
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("Invalid username or password");
        }
    }
}

// DTO 클래스들
class LoginRequest {
    private String username;
    private String password;
    // getters and setters
}

class JwtResponse {
    private String token;

    public JwtResponse(String token) {
        this.token = token;
    }
    // getters
}

설명

이 로그인 API가 하는 일은 사용자가 제공한 인증 정보를 검증하고, 통과하면 앞으로 사용할 열쇠(JWT)를 발급하는 것입니다. 마치 호텔 체크인 시 신분증을 확인하고 룸키를 주는 것과 같습니다.

첫 번째로, authenticationManager.authenticate()는 Spring Security의 강력한 인증 메커니즘을 활용합니다. 내부적으로 UserDetailsService를 호출하여 DB에서 사용자를 조회하고, PasswordEncoder로 비밀번호를 검증합니다.

만약 사용자가 없거나 비밀번호가 틀리면 BadCredentialsException이 발생합니다. 계정이 잠겼거나 만료된 경우에도 적절한 예외가 발생하여 세밀한 에러 처리가 가능합니다.

그 다음으로, 인증이 성공하면 사용자 정보 조회 과정이 실행됩니다. userRepository에서 사용자를 다시 조회하여, 권한 정보를 가져옵니다.

사용자는 여러 역할(ROLE_USER, ROLE_ADMIN 등)을 가질 수 있으므로, Stream API로 역할 이름만 추출하여 리스트로 만듭니다. 마지막으로, jwtTokenProvider.generateToken()으로 토큰을 생성하여 JSON 응답으로 반환합니다.

최종적으로 클라이언트는 이 토큰을 받아서 localStorage나 SecureStorage에 저장하고, 이후 모든 API 요청의 Authorization 헤더에 "Bearer {token}" 형식으로 포함시킵니다. 여러분이 이 로그인 API를 사용하면 사용자는 한 번의 로그인으로 토큰을 받고, 토큰 만료 전까지 재로그인 없이 모든 기능을 사용할 수 있습니다.

또한 예외 처리를 통해 "Invalid username or password" 같은 명확한 에러 메시지를 제공하여 사용자 경험을 개선할 수 있습니다. 실무에서는 로그인 실패 횟수 제한, IP 차단, 로그 기록 등의 보안 기능도 이 API에 추가하는 것이 일반적입니다.

실전 팁

💡 비밀번호는 절대 평문으로 저장하지 마세요. BCryptPasswordEncoder를 사용하여 해싱하고, 회원가입 시 인코딩하세요.

💡 로그인 실패 시 "사용자가 없습니다"와 "비밀번호가 틀렸습니다"를 구분하지 마세요. 계정 존재 여부를 노출하지 않기 위해 동일한 메시지를 사용하세요.

💡 Refresh Token을 함께 발급하면 Access Token 만료 시 재로그인 없이 토큰을 갱신할 수 있어 UX가 향상됩니다.

💡 로그인 시도를 로그로 남기면 보안 감사와 이상 탐지에 유용합니다. IP 주소, 시간, 성공/실패 여부를 기록하세요.

💡 Rate Limiting을 적용하여 무차별 대입 공격(Brute Force)을 방지하세요. Spring의 Bucket4j 라이브러리를 활용할 수 있습니다.


8. UserDetailsService 구현 - 사용자 정보 로딩

시작하며

여러분이 AuthenticationManager를 사용할 때, "DB에서 사용자 정보를 어떻게 가져오지?"라는 의문이 생깁니다. Spring Security는 사용자 정보가 어디에 저장되어 있는지 알 수 없기 때문에, 우리가 직접 알려줘야 합니다.

많은 개발자들이 UserDetailsService의 역할을 제대로 이해하지 못해, 인증이 작동하지 않는 문제를 겪곤 합니다. 이 인터페이스는 Spring Security와 여러분의 사용자 저장소를 연결하는 다리 역할을 합니다.

바로 이럴 때 필요한 것이 커스텀 UserDetailsService 구현입니다. DB에서 사용자를 조회하고, Spring Security가 이해할 수 있는 UserDetails 객체로 변환합니다.

개요

간단히 말해서, UserDetailsService는 사용자 이름을 받아서 해당 사용자의 상세 정보를 반환하는 인터페이스입니다. loadUserByUsername() 메서드 하나만 구현하면 됩니다.

왜 이 인터페이스가 필요할까요? Spring Security는 다양한 저장소(DB, LDAP, 메모리 등)를 지원해야 하므로, 추상화된 인터페이스를 제공합니다.

여러분은 이 인터페이스를 구현하여 JPA로 MySQL에서 조회하든, MongoDB를 사용하든, 외부 API를 호출하든 자유롭게 선택할 수 있습니다. 예를 들어, 처음에는 MySQL을 사용하다가 나중에 Redis로 변경해도 UserDetailsService 구현만 바꾸면 됩니다.

기존에는 InMemoryUserDetailsManager로 메모리에 하드코딩된 사용자를 사용했다면, 실제 애플리케이션에서는 JPA Repository로 DB에서 조회합니다. UserDetailsService 구현의 핵심 요소는 세 가지입니다.

첫째, Repository로 사용자 조회. 둘째, 사용자가 없으면 UsernameNotFoundException 발생.

셋째, User.builder()로 UserDetails 객체 생성하여 username, password, authorities 설정. 이 객체가 반환되면 AuthenticationManager가 비밀번호를 검증하고 권한을 확인합니다.

코드 예제

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. DB에서 사용자 조회
        User user = userRepository.findByUsername(username)
                .orElseThrow(() ->
                    new UsernameNotFoundException("User not found: " + username));

        // 2. 권한 정보를 GrantedAuthority로 변환
        List<GrantedAuthority> authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());

        // 3. Spring Security의 UserDetails 객체로 변환
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())  // 이미 인코딩된 비밀번호여야 함
                .authorities(authorities)
                .accountExpired(false)         // 계정 만료 여부
                .accountLocked(false)          // 계정 잠금 여부
                .credentialsExpired(false)     // 비밀번호 만료 여부
                .disabled(false)               // 계정 비활성화 여부
                .build();
    }
}

// User 엔티티 예시
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String password;  // BCrypt로 인코딩된 비밀번호

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles")
    private Set<Role> roles;

    // getters and setters
}

설명

이 UserDetailsService가 하는 일은 Spring Security와 여러분의 DB 사이의 통역사 역할입니다. Security는 "홍길동이라는 사용자 정보를 줘"라고 요청하면, 이 서비스가 DB를 뒤져서 정보를 찾아 전달합니다.

첫 번째로, loadUserByUsername() 메서드는 AuthenticationManager가 인증 시 자동으로 호출합니다. username 파라미터로 사용자 이름이 들어오면, userRepository.findByUsername()으로 DB를 조회합니다.

Optional을 사용하여 사용자가 없으면 orElseThrow()로 UsernameNotFoundException을 던집니다. 이 예외는 Spring Security가 인식하여 BadCredentialsException으로 변환하고, 최종적으로 로그인 API에서 처리됩니다.

그 다음으로, 조회된 User 엔티티를 Spring Security가 이해할 수 있는 형식으로 변환하는 과정이 실행됩니다. User 엔티티의 roles 컬렉션을 Stream으로 처리하여, 각 Role을 SimpleGrantedAuthority로 변환합니다.

예를 들어 사용자가 "ROLE_USER"와 "ROLE_ADMIN" 역할을 가지고 있다면, 두 개의 GrantedAuthority 객체가 생성됩니다. 마지막으로, UserDetails.User.builder()로 Spring Security 표준 객체를 생성합니다.

username과 password는 필수이고, authorities로 권한을 설정합니다. 최종적으로 accountExpired, accountLocked 등의 플래그로 계정 상태를 표현할 수 있습니다.

예를 들어 관리자가 특정 사용자를 잠갔다면 accountLocked(true)로 설정하여 로그인을 차단할 수 있습니다. 여러분이 이 서비스를 구현하면 Spring Security의 모든 기능(권한 기반 접근 제어, 계정 잠금, 비밀번호 만료 등)을 활용할 수 있습니다.

또한 UserDetailsService는 캐싱과도 잘 어울려, @Cacheable을 추가하면 동일 사용자의 반복 조회를 줄여 성능을 개선할 수 있습니다. 실무에서는 이렇게 표준 인터페이스를 구현하는 것이 프레임워크의 이점을 최대한 활용하는 방법입니다.

실전 팁

💡 User 엔티티의 roles는 EAGER로 페치하세요. 인증 시마다 권한 정보가 필요하므로, LAZY로 하면 N+1 문제가 발생할 수 있습니다.

💡 비밀번호는 반드시 인코딩된 상태로 저장해야 합니다. 회원가입 시 passwordEncoder.encode()로 변환하세요.

💡 사용자 조회를 캐싱하면 성능이 향상되지만, 권한 변경 시 캐시를 즉시 무효화해야 합니다. @CacheEvict를 활용하세요.

💡 email로도 로그인을 지원하고 싶다면 findByUsernameOrEmail() 같은 쿼리 메서드를 사용하세요.

💡 계정 잠금, 비밀번호 만료 등의 기능을 활용하면 보안 정책을 쉽게 구현할 수 있습니다. DB에 해당 필드를 추가하고 UserDetails 생성 시 반영하세요.


9. PasswordEncoder 설정 - 비밀번호 안전하게 저장하기

시작하며

여러분이 회원가입 기능을 만들 때, "비밀번호를 어떻게 저장할 것인가?"는 가장 중요한 보안 결정 중 하나입니다. 평문으로 저장하면 DB 유출 시 모든 계정이 노출되고, MD5 같은 약한 해시는 레인보우 테이블 공격에 취약합니다.

많은 신규 개발자들이 이 부분을 간과하여, 실제 서비스에서 심각한 보안 사고를 경험하곤 합니다. "우리 서비스는 작아서 괜찮겠지"라는 생각은 위험합니다.

바로 이럴 때 필요한 것이 BCryptPasswordEncoder입니다. Spring Security가 권장하는 강력한 단방향 해시 알고리즘으로, Salt와 적응형 해싱을 자동으로 처리합니다.

개요

간단히 말해서, PasswordEncoder는 비밀번호를 암호화하고 검증하는 인터페이스입니다. BCryptPasswordEncoder 구현체는 매번 다른 Salt를 생성하여 동일한 비밀번호도 다른 해시값을 만듭니다.

왜 BCrypt를 사용해야 할까요? MD5나 SHA-256은 너무 빨라서 공격자가 초당 수억 번의 시도를 할 수 있지만, BCrypt는 의도적으로 느리게 설계되어 무차별 대입 공격을 어렵게 만듭니다.

또한 strength 파라미터로 해싱 라운드를 조절하여, 하드웨어 발전에 맞춰 보안 수준을 높일 수 있습니다. 예를 들어, strength 10은 2^10번 해싱을 의미하며, 값을 1 올릴 때마다 연산 시간이 두 배로 증가합니다.

기존에는 개발자가 직접 Salt를 생성하고 관리하며, 해싱 알고리즘을 구현해야 했다면, BCryptPasswordEncoder는 이 모든 것을 자동으로 처리합니다. PasswordEncoder 사용의 핵심 포인트는 네 가지입니다.

첫째, Spring Bean으로 등록하여 DI로 주입받습니다. 둘째, 회원가입 시 encode()로 비밀번호를 인코딩하여 DB에 저장.

셋째, 로그인 시 matches()로 입력 비밀번호와 저장된 해시를 비교. 넷째, AuthenticationManager가 자동으로 PasswordEncoder를 사용하도록 설정.

이렇게 하면 개발자가 비밀번호 비교 로직을 직접 작성할 필요가 없습니다.

코드 예제

@Configuration
public class PasswordEncoderConfig {

    // BCryptPasswordEncoder를 Bean으로 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(10);  // strength 10 (기본값)
    }
}

// 회원가입 서비스 예시
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    // 회원가입 시 비밀번호 인코딩
    public User registerUser(SignupRequest request) {
        User user = new User();
        user.setUsername(request.getUsername());

        // 평문 비밀번호를 BCrypt로 해싱
        String encodedPassword = passwordEncoder.encode(request.getPassword());
        user.setPassword(encodedPassword);

        return userRepository.save(user);
    }

    // 비밀번호 변경 시에도 인코딩 필수
    public void changePassword(Long userId, String newPassword) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("User not found"));

        user.setPassword(passwordEncoder.encode(newPassword));
        userRepository.save(user);
    }
}

// AuthenticationManager 설정에 PasswordEncoder 연결
@Configuration
public class SecurityConfig {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);  // PasswordEncoder 설정
        return provider;
    }
}

설명

이 PasswordEncoder가 하는 일은 비밀번호를 안전한 금고에 보관하는 것입니다. 평문 비밀번호를 받아서 복호화 불가능한 해시로 변환하여, 설령 DB가 유출되어도 원본 비밀번호는 알 수 없게 만듭니다.

첫 번째로, BCryptPasswordEncoder(10)은 strength 10으로 인코더를 생성합니다. 이 숫자가 클수록 보안은 강해지지만 연산 시간도 증가합니다.

일반적으로 10~12가 적절하며, 로그인 시 체감 지연 없이 충분한 보안을 제공합니다. 이 Bean은 애플리케이션 전체에서 재사용되므로, 싱글톤으로 관리됩니다.

그 다음으로, 회원가입 시 encode() 메서드가 실행됩니다. 예를 들어 "mypassword123"을 인코딩하면 "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" 같은 60자 문자열이 생성됩니다.

"$2a"는 BCrypt 버전, "$10"은 strength, 다음은 22자 Salt, 마지막 31자가 실제 해시입니다. 같은 비밀번호도 매번 다른 Salt 때문에 다른 해시가 생성됩니다.

마지막으로, 로그인 시 AuthenticationManager가 자동으로 matches() 메서드를 호출하여 검증합니다. 사용자가 입력한 평문 비밀번호와 DB에 저장된 해시를 비교하는데, BCrypt는 해시 안에 포함된 Salt를 사용하여 동일한 방식으로 입력값을 해싱한 후 비교합니다.

최종적으로 일치하면 인증 성공, 다르면 BadCredentialsException이 발생합니다. 여러분이 BCryptPasswordEncoder를 사용하면 레인보우 테이블 공격, 무차별 대입 공격, 타이밍 공격 등 다양한 비밀번호 공격을 효과적으로 방어할 수 있습니다.

또한 Spring Security와 완벽하게 통합되어 있어, 추가 설정 없이 바로 사용할 수 있습니다. 실무에서는 이렇게 검증된 암호화 라이브러리를 사용하는 것이 자체 구현보다 훨씬 안전합니다.

실전 팁

💡 절대 평문 비밀번호를 로그에 남기지 마세요. 디버깅 시에도 마스킹 처리하거나 제거해야 합니다.

💡 기존 사용자의 비밀번호를 재해싱하려면 로그인 성공 시점에 새로운 strength로 인코딩하여 업데이트하는 마이그레이션 전략을 사용하세요.

💡 비밀번호 정책(최소 길이, 특수문자 포함 등)은 회원가입 시 Validation으로 체크하세요. @Pattern 어노테이션이나 커스텀 Validator를 사용하세요.

💡 테스트 환경에서는 NoOpPasswordEncoder를 사용하여 인코딩 없이 테스트할 수 있지만, 절대 프로덕션에서는 사용하지 마세요.

💡 Argon2나 SCrypt도 좋은 대안이지만, BCrypt가 Spring Security와 가장 잘 통합되어 있고 검증된 선택입니다.


10. Refresh Token 구현 - Access Token 갱신 메커니즘

시작하며

여러분이 JWT를 사용하다 보면 "Access Token의 만료 시간을 어떻게 설정하지?"라는 딜레마에 빠집니다. 짧게 설정하면 보안은 강해지지만 사용자가 자주 재로그인해야 하고, 길게 설정하면 편리하지만 토큰 탈취 시 위험합니다.

많은 서비스들이 이 문제를 해결하지 못해, 토큰 만료 시간을 1주일, 한 달로 길게 설정하여 보안 취약점을 만들어냅니다. 토큰이 한 번 탈취되면 만료 전까지 계속 사용될 수 있습니다.

바로 이럴 때 필요한 것이 Refresh Token 메커니즘입니다. 짧은 만료 시간의 Access Token과 긴 만료 시간의 Refresh Token을 함께 발급하여, 보안과 사용성을 모두 잡습니다.

개요

간단히 말해서, Refresh Token은 새로운 Access Token을 발급받기 위한 특별한 토큰입니다. Access Token은 15분1시간, Refresh Token은 2주1개월 정도의 만료 시간을 가집니다.

왜 두 개의 토큰이 필요할까요? Access Token은 매 API 요청에 포함되므로 탈취 위험이 높습니다.

하지만 짧은 만료 시간 덕분에 탈취되어도 피해를 최소화할 수 있습니다. Refresh Token은 토큰 갱신 시에만 사용되므로 노출 빈도가 낮고, DB에 저장하여 관리할 수 있어 필요시 무효화할 수 있습니다.

예를 들어, 사용자가 "모든 기기에서 로그아웃"을 누르면 DB의 Refresh Token을 삭제하여 모든 세션을 종료시킬 수 있습니다. 기존 단일 토큰 방식에서는 만료 시마다 재로그인해야 했다면, 이중 토큰 방식에서는 Refresh Token으로 자동으로 Access Token을 갱신하여 끊김 없는 사용 경험을 제공합니다.

Refresh Token 메커니즘의 핵심 플로우는 네 단계입니다. 첫째, 로그인 시 Access Token과 Refresh Token을 모두 발급.

둘째, 클라이언트는 Access Token으로 API 호출. 셋째, Access Token 만료 시 Refresh Token으로 갱신 API 호출.

넷째, 새 Access Token 발급 (선택적으로 Refresh Token도 갱신). 이 사이클이 반복되면서 사용자는 재로그인 없이 서비스를 계속 이용할 수 있습니다.

코드 예제

// RefreshToken 엔티티
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String token;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private LocalDateTime expiryDate;

    // getters and setters
}

// RefreshToken 서비스
@Service
public class RefreshTokenService {

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @Value("${jwt.refresh-expiration}")
    private long refreshTokenDuration;  // 예: 1209600000 (2주)

    // Refresh Token 생성
    public RefreshToken createRefreshToken(String username) {
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUsername(username);
        refreshToken.setToken(UUID.randomUUID().toString());  // UUID로 랜덤 토큰 생성
        refreshToken.setExpiryDate(LocalDateTime.now().plusSeconds(refreshTokenDuration / 1000));

        return refreshTokenRepository.save(refreshToken);
    }

    // Refresh Token 검증
    public RefreshToken verifyRefreshToken(String token) {
        RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new RuntimeException("Refresh token not found"));

        if (refreshToken.getExpiryDate().isBefore(LocalDateTime.now())) {
            refreshTokenRepository.delete(refreshToken);  // 만료된 토큰 삭제
            throw new RuntimeException("Refresh token expired");
        }

        return refreshToken;
    }

    // 사용자의 모든 Refresh Token 삭제 (로그아웃)
    public void deleteByUsername(String username) {
        refreshTokenRepository.deleteByUsername(username);
    }
}

// 로그인 API 수정 (Access + Refresh Token 발급)
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    // ... 인증 로직 (이전과 동일)

    String accessToken = jwtTokenProvider.generateToken(user.getUsername(), roles);
    RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getUsername());

    return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken.getToken()));
}

// Token 갱신 API
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest request) {
    RefreshToken refreshToken = refreshTokenService.verifyRefreshToken(request.getRefreshToken());

    User user = userRepository.findByUsername(refreshToken.getUsername())
            .orElseThrow(() -> new RuntimeException("User not found"));

    List<String> roles = user.getRoles().stream()
            .map(role -> role.getName())
            .collect(Collectors.toList());

    String newAccessToken = jwtTokenProvider.generateToken(user.getUsername(), roles);

    return ResponseEntity.ok(new TokenResponse(newAccessToken, refreshToken.getToken()));
}

설명

이 Refresh Token 메커니즘이 하는 일은 보안과 편의성 사이의 균형을 맞추는 것입니다. 마치 건물 출입증(Access Token)이 매일 만료되지만, 사원증(Refresh Token)으로 매일 아침 새 출입증을 발급받는 것과 같습니다.

첫 번째로, createRefreshToken()은 로그인 성공 시 호출되어 새 Refresh Token을 DB에 저장합니다. UUID.randomUUID()로 랜덤한 문자열을 생성하는데, 이는 JWT 형식이 아니라 단순한 고유 식별자입니다.

Access Token은 자가 수용적(self-contained)이지만, Refresh Token은 DB 조회가 필요한 불투명(opaque) 토큰으로 설계하는 것이 일반적입니다. 이렇게 하면 필요시 즉시 무효화할 수 있습니다.

그 다음으로, verifyRefreshToken()이 실행되면서 토큰 갱신 요청을 검증합니다. DB에서 토큰을 조회하고, 만료 시간을 확인합니다.

만약 만료되었다면 DB에서 삭제하여 재사용을 방지합니다. 유효한 토큰이라면 해당 토큰의 username을 사용하여 새 Access Token을 발급합니다.

마지막으로, 토큰 갱신 API가 새로운 Access Token을 반환합니다. 최종적으로 클라이언트는 이 새 Access Token을 저장하고, 이후 API 호출에 사용합니다.

일부 구현에서는 Refresh Token도 함께 갱신하여 Refresh Token Rotation을 구현하기도 합니다. 이렇게 하면 Refresh Token이 탈취되어도 다음 갱신 시 기존 토큰이 무효화되어 더욱 안전합니다.

여러분이 이 메커니즘을 사용하면 Access Token을 15분으로 짧게 설정하여 보안을 강화하면서도, 사용자는 2주 동안 재로그인 없이 서비스를 이용할 수 있습니다. 또한 DB에 Refresh Token을 저장하므로, 계정 도용 의심 시 해당 사용자의 모든 Refresh Token을 삭제하여 강제 로그아웃시킬 수 있습니다.

실무에서는 이렇게 여러 보안 계층을 조합하는 것이 Best Practice입니다.

실전 팁

💡 Refresh Token을 HttpOnly Cookie에 저장하면 XSS 공격으로부터 보호할 수 있습니다. Access Token은 메모리에, Refresh Token은 Cookie에 분리 저장하세요.

💡 Refresh Token Rotation을 구현하면 보안이 더욱 강화됩니다. 갱신할 때마다 새 Refresh Token을 발급하고 기존 토큰은 무효화하세요.

💡 Refresh Token 사용 이력을 로그로 남기면 이상 행동 탐지에 유용합니다. 평소와 다른 시간, 다른 IP에서 갱신 요청이 오면 알림을 보내세요.

💡 DB 대신 Redis에 Refresh Token을 저장하면 성능이 향상됩니다. TTL 설정으로 자동 만료도 가능합니다.

💡 동시 로그인 제한이 필요하다면 사용자당 활성 Refresh Token 개수를 제한하세요. 새 토큰 발급 시 가장 오래된 토큰을 삭제하면 됩니다.


#Java#SpringSecurity#JWT#Authentication#TokenManagement