[아이티센 부트캠프] Spring Security 2

이언덕·2026년 5월 26일

아이티센 부트캠프

목록 보기
109/115
post-thumbnail

패스워드 암호화와 PasswordEncoder

Spring Security에서 비밀번호는 사용자가 입력한 값을 그대로 저장하지 않는다.
비밀번호는 다른 사람이 알면 안 되는 민감한 정보이기 때문에, 저장할 때 안전한 형태로 바꿔야 한다.
이때 사용하는 핵심 인터페이스가 PasswordEncoder이다.


비밀번호 저장에서는 흔히 “비밀번호를 암호화한다”라고 말한다.
하지만 여기서 말하는 암호화는 나중에 다시 원래 값으로 되돌리는 방식이 아니다.
실제 비밀번호 저장에서는 복호화 가능한 암호화가 아니라, 원본 비밀번호를 되돌리기 어렵게 바꾸는 단방향 해시 방식이 사용된다.


PasswordEncoder는 비밀번호를 안전한 저장용 해시 문자열로 바꾸고, 로그인할 때 입력한 비밀번호가 DB에 저장된 해시 문자열과 맞는지 비교하는 역할을 한다.
여기서 중요한 점은 저장된 값을 다시 원래 비밀번호로 되돌려서 비교하지 않는다는 것이다.
비밀번호는 복호화해서 확인하는 값이 아니라, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 보고 DB에 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인하는 값이다.


예를 들어 사용자가 회원가입할 때 비밀번호로 unico123을 입력했다고 하자.
서버는 이 비밀번호 값을 그대로 DB에 저장하지 않는다.
대신 PasswordEncoder를 사용해 unico123이라는 비밀번호를 전혀 다른 긴 문자열로 바꾼 뒤 저장한다.


이때 저장되는 값은 단순히 원본 비밀번호를 숨겨 둔 값이 아니다.
저장된 해시 문자열 안에는 비밀번호 검증에 필요한 정보가 함께 들어 있다.
예를 들면 어떤 해시 방식을 사용했는지, 어떤 salt가 사용되었는지, 최종 해시 결과가 무엇인지 같은 정보가 포함된다.


로그인할 때 사용자가 다시 비밀번호로 unico123을 입력하면 서버는 DB에 저장된 해시 문자열을 원래 비밀번호로 풀지 않는다.
대신 저장된 해시 문자열 안에 들어 있는 정보를 기준으로, 사용자가 방금 입력한 비밀번호 unico123을 다시 계산한다.
그 계산 결과가 저장된 해시 문자열에 포함된 해시 결과와 같으면 올바른 비밀번호로 판단한다.


PasswordEncoder 인터페이스

PasswordEncoder는 비밀번호를 안전하게 다루는 규칙이다

PasswordEncoder는 Spring Security에서 비밀번호 해시와 비밀번호 비교를 담당하는 인터페이스이다.
interface는 어떤 기능을 반드시 가져야 하는지 정해 둔 규칙이라고 이해하면 된다.


비밀번호 처리는 프로젝트마다 직접 제멋대로 만들면 위험하다.
어떤 프로젝트는 비밀번호를 그대로 저장할 수도 있고, 어떤 프로젝트는 약한 방식으로 처리할 수도 있다.
그래서 Spring Security는 PasswordEncoder라는 공통 규칙을 제공한다.
이 규칙을 사용하면 비밀번호를 안전한 방식으로 저장하고 비교하는 흐름을 만들 수 있다.


PasswordEncoder가 하는 일은 크게 두 가지이다.

  • 사용자가 입력한 평문 비밀번호를 해시한다.
  • 로그인할 때 입력한 비밀번호가 DB에 저장된 해시 문자열과 맞는지 비교한다.

여기서 평문 비밀번호는 아직 해시되지 않은 원래 비밀번호를 뜻한다.
예를 들어 사용자가 회원가입 화면에서 비밀번호로 unico123을 입력했다면, 이 값이 평문 비밀번호이다.


다만 PasswordEncoder 자체는 “비밀번호를 해시하고 비교해야 한다”는 규칙에 가깝다.
실제로 어떤 방식으로 계산할지는 BCryptPasswordEncoder 같은 구현체가 담당한다.
그래서 코드에서는 보통 PasswordEncoder 타입으로 사용하고, 실제 객체로는 BCryptPasswordEncoder를 넣어 사용한다.


비밀번호는 단방향 해시로 저장한다

비밀번호 저장에서 중요한 표현은 단방향이다.
단방향은 한쪽 방향으로만 바꿀 수 있다는 뜻이다.
즉, 평문 비밀번호를 해시 문자열로 바꿀 수는 있지만, 해시 문자열을 다시 원래 비밀번호로 되돌리지는 않는다.


예를 들어 비밀번호 unico123을 해시하면 $2a$10$...처럼 전혀 다른 긴 문자열이 만들어진다.
이 문자열을 보고 다시 unico123을 알아내는 방식으로 로그인 검사를 하지 않는다.


처음에는 “로그인할 때 원래 비밀번호와 비교하려면 다시 되돌려야 하는 것 아닌가?”라고 생각할 수 있다.
하지만 비밀번호는 그렇게 처리하지 않는다.
서버는 DB에 저장된 해시 문자열을 복호화하지 않고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 본다.
그 결과가 저장된 해시 문자열에 포함된 해시 결과와 같으면 올바른 비밀번호로 판단한다.


복호화는 암호화된 데이터를 다시 원래 데이터로 되돌리는 과정이다.
하지만 비밀번호 저장에서는 복호화가 가능하면 위험하다.
DB가 유출되었을 때 저장된 비밀번호를 다시 원래 비밀번호로 되돌릴 수 있기 때문이다.
그래서 비밀번호는 복호화할 수 없는 해시 방식으로 관리하는 것이 중요하다.


해시는 값을 되돌릴 수 없게 다른 값으로 바꾸는 방식이다

hash는 어떤 값을 넣었을 때 일정한 계산을 거쳐 전혀 다른 값으로 바꾸는 방식이다.
비밀번호 해시에서는 원본 비밀번호를 직접 알 수 없도록 만드는 것이 핵심이다.


예를 들어 원본 비밀번호가 아래와 같다고 하자.

원본 비밀번호
unico123

이 값을 해시하면 실제 저장값은 아래처럼 전혀 다른 문자열이 된다.

DB에 저장되는 해시 문자열 예시
$2a$10$Kx7...해시결과...

여기서 중요한 점은 저장된 문자열 안에 unico123이라는 원본 비밀번호가 그대로 들어 있는 것이 아니라는 점이다.
DB에 저장되는 값은 원본 비밀번호가 아니라, 원본 비밀번호를 검증하기 위한 해시 문자열이다.


그래서 로그인할 때도 저장된 값을 다시 unico123으로 되돌리지 않는다.
대신 사용자가 입력한 비밀번호를 다시 계산해서, 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인한다.


salt는 같은 비밀번호도 다르게 저장되게 만드는 랜덤값이다

salt는 비밀번호를 해시하기 전에 함께 섞는 랜덤값이다.
쉽게 말하면 비밀번호에 추가로 섞는 임의의 재료라고 보면 된다.


예를 들어 사용자가 비밀번호로 1234를 입력했다고 하자.
salt가 없다면 같은 비밀번호는 같은 해시 결과를 만들 수 있다.

salt가 없는 경우를 단순화한 예시
A 사용자 비밀번호: 1234 -> 해시결과: abc111
B 사용자 비밀번호: 1234 -> 해시결과: abc111

이렇게 되면 DB를 본 사람이 두 사용자가 같은 비밀번호를 사용한다는 사실을 알 수 있다.
또 공격자가 미리 만들어 둔 해시 목록과 비교하기도 쉬워진다.


하지만 salt를 섞으면 같은 비밀번호라도 저장 결과가 달라진다.

salt가 있는 경우를 단순화한 예시
A 사용자 비밀번호: 1234 + saltA -> 해시결과: aaa111
B 사용자 비밀번호: 1234 + saltB -> 해시결과: bbb222

두 사용자의 원본 비밀번호는 모두 1234이다.
하지만 각각 다른 salt가 섞였기 때문에 DB에 저장되는 해시 결과는 서로 달라진다.


BCryptPasswordEncoder가 만든 해시 문자열에는 비교에 필요한 salt 정보도 함께 들어 있다.
그래서 로그인할 때 matches()는 저장된 해시 문자열에서 salt와 설정 정보를 읽고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산할 수 있다.


salt는 비밀번호를 숨기는 비밀키가 아니라, 같은 비밀번호도 서로 다른 결과로 저장되게 만드는 랜덤값이다.


PasswordEncoder 주요 메서드

encode()는 평문 비밀번호를 해시 문자열로 바꾼다

encode()는 사용자가 입력한 평문 비밀번호를 저장 가능한 해시 문자열로 바꾸는 메서드이다.
메서드는 객체가 가진 기능이라고 이해하면 된다.


회원가입 상황을 생각하면 이해하기 쉽다.
사용자가 회원가입 화면에서 비밀번호 unico123을 입력한다.
서버는 이 값을 그대로 DB에 저장하지 않는다.
먼저 passwordEncoder.encode("unico123")을 실행한다.
그러면 unico123은 저장 가능한 긴 해시 문자열로 바뀐다.
그리고 서버는 이 해시 문자열을 DB에 저장한다.


흐름은 아래처럼 볼 수 있다.

// PasswordEncoderEncodeFlow.java
// BCrypt 방식으로 비밀번호를 처리할 PasswordEncoder를 만든다.
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

// 사용자가 입력한 평문 비밀번호를 준비한다.
String rawPassword = "unico123";

// 평문 비밀번호를 해시 문자열로 바꾼다.
String encodedPassword = passwordEncoder.encode(rawPassword);

rawPassword는 사용자가 입력한 원본 비밀번호이다.
encodedPassword는 PasswordEncoder가 만든 저장용 해시 문자열이다.
실제 저장소에는 rawPassword가 아니라 encodedPassword가 저장되어야 한다.


encode()가 실행될 때의 흐름을 더 풀어 보면 다음과 같다.

  • 원본 비밀번호 unico123을 받는다.
  • BCryptPasswordEncoder가 랜덤 salt를 만든다.
  • 원본 비밀번호와 salt를 함께 사용해 해시 계산을 한다.
  • 해시 방식, 반복 강도, salt, 해시 결과가 포함된 문자열을 만든다.
  • 이 최종 문자열을 DB에 저장한다.

즉, encode()의 결과는 단순한 해시 결과 하나만이 아니다.
비밀번호를 나중에 검증할 때 필요한 정보까지 포함된 저장용 문자열이다.


예를 들면 저장값은 아래와 같은 느낌으로 만들어진다.

// 실제 값은 실행할 때마다 달라질 수 있다.
rawPassword = "unico123";
encodedPassword = "$2a$10$Kx7...salt정보...해시결과...";

여기서 rawPassword는 절대 저장하면 안 되는 값이다.
encodedPassword가 DB에 저장해야 하는 값이다.


encode()를 다시 실행해서 문자열 비교하면 안 된다

BCryptPasswordEncoder는 같은 비밀번호를 다시 encode()해도 매번 같은 문자열이 나오지 않을 수 있다.
이유는 encode()를 실행할 때마다 새로운 salt가 만들어질 수 있기 때문이다.


예를 들어 같은 비밀번호 unico123을 두 번 해시해도 결과가 다를 수 있다.

// 같은 비밀번호를 두 번 encode()한 예시
passwordEncoder.encode("unico123");
// 결과 예시: $2a$10$AAA...해시결과...

passwordEncoder.encode("unico123");
// 결과 예시: $2a$10$BBB...해시결과...

둘 다 원본 비밀번호는 unico123이다.
하지만 salt가 다르기 때문에 최종 해시 문자열은 서로 다르게 나올 수 있다.


그래서 로그인할 때 아래처럼 비교하면 안 된다.

// 잘못된 비교 방식
passwordEncoder.encode("unico123").equals(encodedPassword);

이 방식은 사용자가 입력한 비밀번호를 다시 encode()한 뒤 문자열이 완전히 같은지 비교하려고 한다.
하지만 새로 encode()하면 새로운 salt가 만들어질 수 있으므로 저장된 문자열과 달라질 수 있다.


그래서 로그인 비교에는 matches()를 사용해야 한다.

// 올바른 비교 방식
passwordEncoder.matches("unico123", encodedPassword);

matches()는 저장된 해시 문자열 안에 있는 salt와 설정 정보를 기준으로 입력 비밀번호를 다시 계산한다.
그래서 같은 비밀번호인지 안전하게 확인할 수 있다.


matches()는 입력 비밀번호를 같은 조건으로 다시 계산해 비교한다

matches()는 로그인할 때 사용하는 메서드이다.
사용자가 입력한 평문 비밀번호와 DB에 저장된 해시 문자열이 맞는지 확인한다.


로그인 상황을 생각하면 된다.
사용자가 로그인 화면에 비밀번호로 unico123을 입력한다.
DB에는 이미 회원가입 때 만들어진 해시 문자열이 저장되어 있다.
서버는 저장된 해시 문자열을 원래 값으로 되돌리지 않는다.
대신 matches()를 사용해서 입력값을 같은 조건으로 다시 계산하고, 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인한다.


흐름은 아래처럼 볼 수 있다.

// PasswordEncoderMatchesFlow.java
// BCrypt 방식으로 비밀번호를 비교할 PasswordEncoder를 만든다.
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

// 사용자가 로그인 화면에 입력한 비밀번호를 준비한다.
String rawPassword = "unico123";

// DB에 저장되어 있다고 가정할 해시 문자열을 만든다.
String encodedPassword = passwordEncoder.encode(rawPassword);

// 입력 비밀번호와 저장된 해시 문자열을 비교한다.
boolean match = passwordEncoder.matches(rawPassword, encodedPassword);

match가 true이면 입력한 비밀번호가 맞다는 뜻이다.
false이면 입력한 비밀번호가 저장된 해시 문자열과 맞지 않는다는 뜻이다.


matches()가 내부에서 하는 일을 단계별로 풀면 다음과 같다.

  • 첫 번째 값으로 사용자가 입력한 평문 비밀번호를 받는다.
  • 두 번째 값으로 DB에 저장된 해시 문자열을 받는다.
  • 저장된 해시 문자열에서 salt와 설정 정보를 읽는다.
  • 사용자가 입력한 비밀번호를 같은 salt와 설정으로 다시 해시한다.
  • 다시 계산한 결과와 저장된 해시 문자열에 포함된 해시 결과를 비교한다.
  • 결과가 같으면 true, 다르면 false를 반환한다.

즉, matches()는 암호화된 비밀번호를 복호화해서 비교하는 메서드가 아니다.
저장된 해시 문자열의 조건을 이용해, 지금 입력한 비밀번호를 다시 계산해 보고 결과가 맞는지 확인하는 메서드이다.


예를 들어 저장된 해시 문자열이 아래와 같다고 하자.

DB에 저장된 해시 문자열 예시
encodedPassword = "$2a$10$Kx7...해시결과...";

사용자가 올바른 비밀번호를 입력하면 아래처럼 판단된다.

올바른 비밀번호 입력
입력값: unico123
저장된 salt로 다시 계산한 결과: 저장된 해시 문자열에 포함된 해시 결과와 같음
matches() 결과: true

사용자가 틀린 비밀번호를 입력하면 아래처럼 판단된다.

잘못된 비밀번호 입력
입력값: unico999
저장된 salt로 다시 계산한 결과: 저장된 해시 문자열에 포함된 해시 결과와 다름
matches() 결과: false

이 흐름 때문에 비밀번호를 원래 값으로 되돌리지 않아도 로그인 검사가 가능하다.


matches()는 저장된 해시 문자열에서 salt와 설정 정보를 읽고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 비교한다.


PasswordEncoder 구현체

구현체는 실제 해시 방식을 담당한다

PasswordEncoder는 비밀번호 해시와 비교에 대한 공통 규칙이다.
하지만 실제로 어떤 알고리즘으로 비밀번호를 해시할지는 구현체가 담당한다.
구현체는 인터페이스가 정한 기능을 실제 코드로 만든 클래스라고 이해하면 된다.


앞에서 encode()와 matches()를 설명했기 때문에 갑자기 구현체가 왜 나오는지 헷갈릴 수 있다.
정리하면 관계는 이렇다.

  • PasswordEncoder는 비밀번호를 해시하고 비교해야 한다는 규칙이다.
  • encode()와 matches()는 그 규칙에 포함된 대표 메서드이다.
  • BCryptPasswordEncoder 같은 구현체는 그 메서드들을 실제로 동작하게 만드는 클래스이다.

즉, PasswordEncoder와 구현체를 따로따로 사용하는 것이 아니다.
PasswordEncoder 타입으로 사용하되, 실제 객체로는 BCryptPasswordEncoder 같은 구현체를 넣어서 사용한다.


코드로 보면 아래와 같다.

// PasswordEncoder 타입으로 받고,
// 실제 동작은 BCryptPasswordEncoder 구현체가 담당한다.
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

왼쪽의 PasswordEncoder는 공통 규칙이다.
오른쪽의 BCryptPasswordEncoder는 실제 해시 계산을 수행하는 클래스이다.


이렇게 작성하면 코드 전체는 PasswordEncoder라는 공통 타입에 의존한다.
그리고 실제 비밀번호 처리 방식은 BCryptPasswordEncoder가 담당한다.


Spring Security에서 사용할 수 있는 대표 구현체

Spring Security에서 사용할 수 있는 대표 구현체는 다음과 같다.

  • BCryptPasswordEncoder
  • Pbkdf2PasswordEncoder
  • SCryptPasswordEncoder
  • Argon2PasswordEncoder

각 구현체는 비밀번호를 안전하게 해시하는 방식이 다르다.
같은 PasswordEncoder 규칙을 따르지만, 내부 계산 방식은 다를 수 있다.


예를 들어 BCryptPasswordEncoder는 bcrypt 해시 방식을 사용한다.
Pbkdf2PasswordEncoder는 PBKDF2 방식을 사용한다.
SCryptPasswordEncoder는 scrypt 방식을 사용한다.
Argon2PasswordEncoder는 Argon2 방식을 사용한다.


실습에서는 보통 BCryptPasswordEncoder를 많이 사용한다.
그래서 처음에는 PasswordEncoder라는 규칙이 있고, 실제로는 BCryptPasswordEncoder가 그 규칙을 구현해서 동작한다고 이해하면 된다.


BCryptPasswordEncoder는 자주 사용되는 비밀번호 해시 방식이다

BCryptPasswordEncoder는 bcrypt 해시 방식을 사용하는 구현체이다.
Spring Security에서 비밀번호 해시 예제로 자주 등장하는 방식이다.


BCryptPasswordEncoder를 사용하면 같은 비밀번호를 해시해도 결과 문자열이 매번 같지 않을 수 있다.
처음 보면 이상해 보일 수 있다.
“같은 비밀번호인데 왜 결과가 다르지?”라고 생각할 수 있다.


이유는 salt 때문이다.
salt는 비밀번호를 해시할 때 함께 섞는 랜덤값이다.
같은 비밀번호라도 서로 다른 salt가 섞이면 해시 결과가 달라진다.
이렇게 하면 공격자가 미리 만들어 둔 비밀번호 해시 목록으로 쉽게 맞히기 어렵다.


예를 들어 두 사람이 같은 1234 비밀번호를 사용한다고 해도, salt가 다르면 저장되는 해시 결과가 달라진다.
그래서 같은 비밀번호를 쓰는 사용자가 있어도 DB에 같은 문자열이 그대로 저장되지 않는다.


BCryptPasswordEncoder를 Bean으로 등록하면 Spring Security 인증 흐름에서 비밀번호 비교에 사용할 수 있다.

// SecurityPasswordConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityPasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt 방식으로 비밀번호를 해시하고 비교한다.
        return new BCryptPasswordEncoder();
    }

}

이 설정은 PasswordEncoder 타입의 객체를 Spring Bean으로 등록한다.
Spring Bean은 Spring이 만들고 관리하는 객체이다.
이렇게 등록해 두면 로그인 인증 흐름에서 Spring Security가 이 객체를 사용해 비밀번호를 비교할 수 있다.


여기서도 관계는 같다.
메서드를 사용할 때는 PasswordEncoder의 encode()와 matches()를 사용한다.
하지만 실제 해시 계산은 BCryptPasswordEncoder 구현체가 담당한다.


비밀번호 해시 예제 흐름

PasswordEncryptionExample 코드

아래 예제는 BCryptPasswordEncoder로 비밀번호를 해시하고, 올바른 비밀번호와 잘못된 비밀번호를 각각 비교하는 흐름을 보여준다.

// PasswordEncryptionExample.java
package com.example.security9.app;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

public class PasswordEncryptionExample {
    public static void main(String[] args) {
        // 원본 비밀번호를 준비한다.
        String rawPassword = "unico123";

        // BCrypt 방식의 PasswordEncoder를 생성한다.
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        // 원본 비밀번호를 저장용 해시 문자열로 바꾼다.
        String encodedPassword = passwordEncoder.encode(rawPassword);
        System.out.println("암호화된 비밀번호: " + encodedPassword);

        // 원본 비밀번호와 해시 문자열이 일치하는지 확인한다.
        boolean isMatch = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("비밀번호 일치 여부: " + isMatch);

        // 잘못된 비밀번호를 준비한다.
        String wrongPassword = "unico999";

        // 잘못된 비밀번호와 해시 문자열이 일치하는지 확인한다.
        boolean wrongMatch = passwordEncoder.matches(wrongPassword, encodedPassword);
        System.out.println("잘못된 비밀번호 일치 여부: " + wrongMatch);
    }
}

이 코드에서 rawPassword에는 원본 비밀번호 unico123이 들어간다.
passwordEncoder.encode(rawPassword)는 이 원본 비밀번호를 저장 가능한 해시 문자열로 바꾼다.
결과는 encodedPassword에 저장된다.


코드의 출력 문구는 암호화된 비밀번호라고 되어 있다.
하지만 개념적으로 보면 이 값은 다시 복호화할 수 있는 암호문이 아니라, 비밀번호 검증에 사용할 저장용 해시 문자열이다.


encodedPassword에는 원본 비밀번호가 그대로 들어 있지 않다.
대신 해시 방식, salt, 해시 결과처럼 나중에 검증할 때 필요한 정보가 포함된다.
그래서 DB에는 rawPassword가 아니라 encodedPassword를 저장해야 한다.


그 다음 passwordEncoder.matches(rawPassword, encodedPassword)가 실행된다.
첫 번째 값은 사용자가 입력한 평문 비밀번호이고, 두 번째 값은 저장되어 있다고 가정하는 해시 문자열이다.
matches()는 저장된 해시 문자열에서 salt와 설정 정보를 읽고, 입력한 비밀번호를 같은 조건으로 다시 계산한다.
계산 결과가 저장된 해시 문자열에 포함된 해시 결과와 맞으면 결과는 true가 된다.


마지막으로 wrongPassword에는 unico999가 들어간다.
이 값은 원래 비밀번호인 unico123과 다르다.
그래서 같은 salt와 설정으로 다시 계산해도 저장된 해시 문자열에 포함된 해시 결과와 맞지 않는다.
따라서 passwordEncoder.matches(wrongPassword, encodedPassword)의 결과는 false가 된다.


실행 결과 확인

// 출력결과
// 암호화된 비밀번호: $2a$10$...
// 비밀번호 일치 여부: true
// 잘못된 비밀번호 일치 여부: false

암호화된 비밀번호 값은 실행할 때마다 달라질 수 있다.
BCryptPasswordEncoder가 해시 과정에서 새로운 salt를 사용할 수 있기 때문이다.
그래도 matches()는 저장된 해시 문자열에 포함된 salt와 설정 정보를 바탕으로 비교할 수 있다.
그래서 같은 원본 비밀번호인 unico123은 true가 되고, 다른 비밀번호인 unico999는 false가 된다.

BCryptPasswordEncoder로 unico123을 해시하면 원본 비밀번호와 전혀 다른 긴 해시 문자열이 만들어진다.
이 문자열은 저장용 비밀번호 값으로 사용되며, 다시 원래 비밀번호로 되돌리는 값이 아니다.
로그인할 때는 matches()가 저장된 해시 문자열 안의 salt와 설정 정보를 이용해 입력 비밀번호를 다시 계산한다.
그래서 올바른 비밀번호인 unico123은 true, 잘못된 비밀번호인 unico999는 false가 된다.


비밀번호 해시 보안 권장사항

평문 비밀번호는 저장하면 안 된다

비밀번호에서 가장 중요한 원칙은 평문 저장 금지이다.
평문 저장은 사용자가 입력한 비밀번호를 그대로 저장하는 것이다.
예를 들어 DB에 unico123이 그대로 저장되어 있다면 평문 저장이다.


이 방식은 매우 위험하다.
만약 DB가 유출되면 사용자의 비밀번호가 그대로 노출된다.
사용자가 같은 비밀번호를 다른 사이트에서도 사용했다면 피해가 더 커질 수 있다.


그래서 비밀번호는 반드시 해시된 형태로 저장해야 한다.
해시된 형태는 원래 비밀번호를 직접 알 수 없는 저장 결과이다.
로그인할 때도 저장된 해시 값을 되돌리지 않고, 입력 비밀번호를 같은 조건으로 다시 계산해 맞는지 비교해야 한다.


최신 비밀번호 해시 알고리즘을 사용하는 것이 중요하다

비밀번호 저장에는 안전한 해시 알고리즘을 사용해야 한다.
BCryptPasswordEncoder, Pbkdf2PasswordEncoder, SCryptPasswordEncoder, Argon2PasswordEncoder 같은 구현체는 비밀번호 해시에 사용되는 방식이다.


이런 방식들은 단순히 문자열을 바꾸는 수준이 아니다.
salt 같은 임의값을 적용해서 같은 비밀번호라도 결과가 다르게 만들어질 수 있게 한다.
또 계산을 일부러 무겁게 만들어서 공격자가 많은 비밀번호를 빠르게 대입해 맞히는 것을 어렵게 만든다.


무차별 대입 공격은 가능한 비밀번호를 계속 넣어 보면서 맞히는 공격이다.
예를 들어 1234, 1111, password, admin123 같은 값을 계속 시도하는 방식이다.
salt와 안전한 해시 방식이 적용되면 이런 공격을 훨씬 어렵게 만들 수 있다.


회원가입과 로그인 흐름에서 PasswordEncoder가 쓰이는 위치

PasswordEncoder는 회원가입과 로그인 흐름 모두에서 사용된다.
다만 사용하는 방식은 다르다.


회원가입에서는 encode()를 사용한다.
사용자가 입력한 비밀번호를 해시해서 저장해야 하기 때문이다.


로그인에서는 matches()를 사용한다.
사용자가 입력한 비밀번호가 저장된 해시 문자열과 맞는지 확인해야 하기 때문이다.


흐름을 정리하면 다음과 같다.

  • 회원가입: 평문 비밀번호 입력 → encode() 실행 → 해시 문자열 저장
  • 로그인: 평문 비밀번호 입력 → 저장된 해시 문자열 조회 → matches() 실행 → 일치 여부 확인

회원가입에서는 비밀번호를 해시해서 저장하고, 로그인에서는 저장된 해시 문자열의 조건으로 입력 비밀번호를 다시 계산해 비교한다.
이 흐름을 이해해야 이후 DB 기반 로그인이나 JPA 연동 로그인에서 비밀번호 검증 구조를 자연스럽게 이해할 수 있다.


패스워드 암호화 핵심 정리

PasswordEncoder는 비밀번호를 안전하게 해시하고 비교하기 위한 Spring Security의 핵심 인터페이스이다.
encode()는 평문 비밀번호를 저장 가능한 해시 문자열로 바꾸고, matches()는 입력 비밀번호와 저장된 해시 문자열이 맞는지 비교한다.


비밀번호는 복호화해서 확인하지 않는다.
저장된 해시 문자열을 원래 비밀번호로 되돌리는 방식이 아니라, 입력된 비밀번호를 저장된 해시 문자열의 조건으로 다시 계산해 보고 결과가 맞는지 확인한다.


BCryptPasswordEncoder는 bcrypt 해시 방식을 사용하는 대표 구현체이다.
salt가 적용되기 때문에 같은 비밀번호라도 해시 결과가 달라질 수 있고, 이 특징은 같은 비밀번호를 사용한 사용자끼리도 저장값이 같아지는 것을 막는 데 도움이 된다.
또 공격자가 미리 만들어 둔 해시 목록이나 무차별 대입 방식으로 비밀번호를 맞히는 것을 어렵게 만든다.


비밀번호는 절대 평문으로 저장하면 안 되고, 항상 안전한 해시 방식으로 바꿔 저장해야 한다.




security9: PasswordEncoder와 Remember-Me 자동 로그인 실습

security9는 앞에서 정리한 비밀번호 암호화 흐름을 확인한 뒤, 직접 만든 로그인 화면에 Remember-Me 자동 로그인 기능을 추가하는 예제이다.
비밀번호 암호화는 PasswordEncryptionExample로 이미 확인했고, 이 프로젝트에서 새로 집중해야 할 부분은 Remember-Me 설정이다.


Remember-Me는 사용자가 로그인할 때 자동 로그인을 선택하면, 일반 세션이 사라진 뒤에도 일정 시간 동안 다시 인증 상태를 복원할 수 있게 돕는 기능이다.
일반 로그인은 보통 JSESSIONID 세션 쿠키를 기준으로 로그인 상태를 유지한다.
하지만 Remember-Me를 사용하면 remember-me 쿠키가 추가로 만들어지고, 이 쿠키를 통해 다시 인증된 사용자로 복원될 수 있다.


security9의 핵심은 remember-me 체크박스와 SecurityConfig의 rememberMe() 설정을 연결하고, 세션 쿠키가 사라져도 자동 로그인 쿠키로 인증이 복원되는 흐름을 확인하는 것이다.


security9 프로젝트에서 확인할 핵심

PasswordEncoder 확인 후 Remember-Me로 넘어간다

security9 프로젝트에는 PasswordEncryptionExample이 포함되어 있다.
이 예제는 BCryptPasswordEncoder로 비밀번호를 암호화하고, matches()로 올바른 비밀번호와 잘못된 비밀번호를 비교하는 흐름을 보여준다.


하지만 이 내용은 앞의 # 패스워드 암호화와 PasswordEncoder 구간에서 이미 정리했다.
따라서 security9 본문에서는 암호화 예제를 길게 반복하지 않고, 프로젝트 안에 들어 있는 확인용 예제라는 정도로만 연결한다.


security9에서 실제로 새롭게 확인할 흐름은 다음과 같다.

  • Security9Application으로 프로젝트를 실행한다.
  • application.yml에서 기본 로그인 계정을 확인한다.
  • SecurityConfig에서 커스텀 로그인과 Remember-Me를 설정한다.
  • login.html에서 자동 로그인 체크박스를 추가한다.
  • 로그인 성공 후 remember-me 쿠키가 생성되는지 확인한다.
  • JSESSIONID를 삭제해도 remember-me 쿠키로 인증이 복원되는지 확인한다.
  • 로그아웃하면 세션과 자동 로그인 정보가 정리되는지 확인한다.

이 흐름을 보면 Remember-Me가 단순히 체크박스 하나를 추가하는 기능이 아니라, 로그인 요청, 쿠키 생성, 세션 소멸 후 인증 복원, 로그아웃 시 자동 로그인 정보 정리까지 연결되는 보안 기능이라는 것을 알 수 있다.


security4와 security9의 차이

security4에서는 직접 만든 로그인 화면과 로그아웃 흐름을 확인했다.
로그인에 성공하면 세션을 기준으로 인증 상태가 유지되었다.
브라우저가 JSESSIONID 쿠키를 보내면 서버는 해당 세션을 찾아 로그인 상태를 확인할 수 있었다.


security9에서는 여기에 Remember-Me가 추가된다.
Remember-Me는 일반 세션 로그인과 완전히 같은 것은 아니다.
세션이 사라졌을 때도 remember-me 쿠키가 남아 있으면 Spring Security가 이 쿠키를 이용해 사용자를 다시 인증된 상태로 복원할 수 있다.


쉽게 말하면 일반 로그인은 “현재 세션 동안 로그인 상태를 유지하는 방식”이다.
Remember-Me는 “세션이 사라져도 정해진 기간 동안 자동으로 다시 로그인 상태를 복원할 수 있는 방식”이다.


Remember-Me는 세션을 대신하는 기능이 아니라, 세션이 없어진 뒤에도 사용자를 다시 인증할 수 있게 도와주는 보조 인증 기능이다.


PasswordEncryptionExample은 비밀번호 암호화 확인용 예제이다

프로젝트 안에 포함된 확인용 실행 코드이다

PasswordEncryptionExample은 BCryptPasswordEncoder로 비밀번호 암호화와 비교를 확인하는 독립 실행 예제이다.
이 코드는 웹 로그인 화면과 직접 연결되어 동작하는 코드는 아니다.
main() 메서드를 직접 실행해서 콘솔 결과를 확인한다.


이 예제에서 확인하는 흐름은 다음과 같다.

  • 원본 비밀번호 unico123을 준비한다.
  • BCryptPasswordEncoder로 원본 비밀번호를 저장용 해시 문자열로 바꾼다.
  • matches()로 unico123이 저장된 해시 문자열과 일치하는지 확인한다.
  • 잘못된 비밀번호 unico999를 비교하면 false가 나오는지 확인한다.

BCryptPasswordEncoder의 암호화 결과는 실행할 때마다 달라질 수 있다.
하지만 matches()는 저장된 해시 문자열에 포함된 salt와 설정 정보를 기준으로 입력 비밀번호를 다시 계산해 비교할 수 있다.


이 예제는 Remember-Me 자체를 구현하는 코드는 아니다.
다만 실제 로그인 시스템에서는 비밀번호를 평문으로 저장하지 않고 안전하게 비교해야 하므로, security9 프로젝트 안에서 함께 확인하는 기초 예제로 보면 된다.


Security9Application은 프로젝트 실행 시작 클래스이다

실행 클래스의 역할

Security9Application은 security9 프로젝트를 실행하는 시작 클래스이다.
이 파일에는 로그인 설정이나 Remember-Me 설정이 들어 있지 않다.
main() 메서드에서 SpringApplication.run()을 호출해 Spring Boot 애플리케이션을 실행한다.

// Security9Application.java
package com.example.security9;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Security9Application {

    public static void main(String[] args) {
        // Spring Boot 애플리케이션을 실행한다.
        SpringApplication.run(Security9Application.class, args);
    }

}

Security9Application은 애플리케이션을 시작하는 역할만 한다.
보안 규칙은 SecurityConfig에서 작성한다.
로그인 화면은 LoginController와 login.html이 담당한다.
로그인 성공 후 화면은 MemberController와 member.html이 담당한다.


즉, 실행 클래스는 출발점이고, 실제 로그인과 자동 로그인 흐름은 다른 클래스와 화면 파일이 나누어 담당한다.


application.yml은 기본 사용자 정보를 설정한다

security9의 기본 로그인 계정

application.yml에는 애플리케이션 이름과 기본 사용자 정보가 들어 있다.
security9에서는 이 기본 사용자 정보로 로그인한다.

# application.yml
spring:
  application:
    name: security9
  security:
    user:
      name: unico
      password: 1234
      roles: USER

이 설정에서 기본 로그인 사용자는 unico이다.
비밀번호는 1234이다.
권한은 USER이다.


server.port 설정은 따로 작성되어 있지 않다.
따라서 기본 포트인 8080으로 실행된다.
브라우저에서는 http://localhost:8080/login으로 로그인 화면에 접속한다.


이 계정 정보는 Spring Security가 인증할 때 사용한다.
따라서 로그인 화면에서 계정에 unico, 암호에 1234를 입력하면 인증에 성공할 수 있다.


SecurityConfig에서 로그인과 Remember-Me를 설정한다

SecurityConfig 전체 코드

SecurityConfig는 security9의 보안 설정 클래스이다.
@Configuration이 붙어 있으므로 Spring은 이 클래스를 설정 클래스로 읽는다.


이 설정에서는 /login만 인증 없이 접근할 수 있게 열어 둔다.
그 밖의 요청은 로그인한 사용자만 접근할 수 있다.
또한 커스텀 로그인 화면과 로그인 처리 주소, Remember-Me, 로그아웃 흐름을 함께 설정한다.

// SecurityConfig.java
package com.example.security9.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // HTTP 요청에 대한 보안 설정을 작성한다.
            .authorizeHttpRequests(requests -> requests
                    // /login은 인증 없이 접근할 수 있다.
                    .requestMatchers("/login").permitAll()
                    // 그 밖의 요청은 인증이 필요하다.
                    .anyRequest().authenticated())
            // 실습 편의를 위해 CSRF 방어 기능을 비활성화한다.
            .csrf((csrf) -> csrf.disable())
            // 폼 기반 로그인 설정을 작성한다.
            .formLogin(form -> form
                    // 직접 만든 로그인 페이지 URL을 지정한다.
                    .loginPage("/login")
                    // 로그인 인증을 처리할 URL을 지정한다.
                    .loginProcessingUrl("/authentication")
                    // 사용자명 input의 name 값을 지정한다.
                    .usernameParameter("usernameInput")
                    // 비밀번호 input의 name 값을 지정한다.
                    .passwordParameter("passwordInput")
                    // 로그인 성공 시 이동할 URL을 지정한다.
                    .defaultSuccessUrl("/", true)
                    // 로그인 실패 시 이동할 URL을 지정한다.
                    .failureUrl("/login?error"))
            // Remember-Me 자동 로그인 설정을 작성한다.
            .rememberMe(remember -> remember
                    // Remember-Me 토큰을 만들고 검증할 때 사용할 key를 지정한다.
                    .key("security9-remember-me-key")
                    // 로그인 화면의 체크박스 name 값과 연결한다.
                    .rememberMeParameter("remember-me")
                    // 자동 로그인 유지 시간을 1시간으로 설정한다.
                    .tokenValiditySeconds(60 * 60 * 1))
            // 로그아웃 설정을 작성한다.
            .logout(logout -> logout
                    // 로그아웃을 처리할 URL을 지정한다.
                    .logoutUrl("/logout")
                    // 로그아웃 성공 시 이동할 URL을 지정한다.
                    .logoutSuccessUrl("/login?logout")
                    // 로그아웃 시 세션을 무효화한다.
                    .invalidateHttpSession(true)
                    // 로그아웃 시 세션 쿠키를 삭제한다.
                    .deleteCookies("JSESSIONID"));

        // 설정한 내용으로 SecurityFilterChain을 만든다.
        SecurityFilterChain chain = http.build();
        // 생성된 보안 필터 목록을 콘솔에 출력한다.
        chain.getFilters().forEach(System.out::println);
        // 완성된 보안 필터 체인을 반환한다.
        return chain;
    }
}

이 코드에서 authorizeHttpRequests()는 요청별 접근 규칙을 설정한다.
/login은 로그인 화면이므로 인증되지 않은 사용자도 접근할 수 있어야 한다.
그래서 permitAll()로 열어 둔다.
그 밖의 요청은 authenticated()가 적용되어 로그인한 사용자만 접근할 수 있다.


csrf((csrf) -> csrf.disable())은 CSRF 방어 기능을 비활성화하는 설정이다.
이번 실습에서는 로그인과 로그아웃 흐름을 단순하게 확인하기 위해 비활성화했다.
이 설정 때문에 member.html의 버튼처럼 GET /logout 방식으로도 로그아웃 테스트가 가능하다.


formLogin()은 폼 로그인 방식을 설정한다.
loginPage("/login")은 직접 만든 로그인 화면 주소이다.
loginProcessingUrl("/authentication")은 로그인 정보를 제출할 주소이다.
이 요청은 Controller가 직접 처리하지 않고 Spring Security 필터가 처리한다.


usernameParameter("usernameInput")과 passwordParameter("passwordInput")은 로그인 폼의 입력 필드 이름과 맞춰 주는 설정이다.
이 이름이 login.html의 th:field 결과와 맞아야 Spring Security가 요청에서 계정과 암호를 정확히 꺼낼 수 있다.


defaultSuccessUrl("/", true)는 로그인 성공 후 항상 /로 이동하게 한다.
failureUrl("/login?error")는 로그인 실패 후 다시 로그인 화면으로 돌아가게 한다.
이때 error 파라미터가 붙으므로 로그인 화면에서 실패 메시지를 보여줄 수 있다.


Remember-Me 설정 자세히 보기

rememberMe()는 자동 로그인 기능을 설정한다.
자동 로그인은 사용자가 로그인할 때 체크박스를 선택하면, 이후 세션이 사라져도 일정 시간 동안 다시 인증 상태를 복원할 수 있게 하는 기능이다.


key("security9-remember-me-key")는 Remember-Me 토큰을 만들고 검증할 때 사용하는 기준 값이다.
이 값은 서버가 자동 로그인 쿠키를 신뢰할 수 있는지 판단할 때 사용된다.
프로젝트마다 예측하기 어려운 값으로 관리하는 것이 좋다.


rememberMeParameter("remember-me")는 로그인 화면의 체크박스 이름과 연결된다.
즉, login.html의 체크박스 name 값이 remember-me여야 한다.
사용자가 이 체크박스를 선택하면 요청 파라미터에 remember-me 값이 포함되고, Spring Security는 자동 로그인 기능을 켜야 한다고 판단한다.


tokenValiditySeconds(60 * 60 * 1)은 자동 로그인 유지 시간을 초 단위로 설정한다.
60 * 60 * 1은 3600초이고, 1시간을 의미한다.
즉, 자동 로그인 쿠키는 설정된 시간 동안 사용자를 다시 인증하는 데 사용될 수 있다.


로그아웃 설정에서 .deleteCookies("JSESSIONID")는 세션 쿠키를 삭제하는 설정이다.
여기에는 remember-me 쿠키 이름이 직접 적혀 있지 않다.
하지만 rememberMe() 설정이 적용되어 있으면 Spring Security의 로그아웃 처리 과정에서 자동 로그인에 사용하던 remember-me 쿠키도 함께 무효화된다.
그래서 로그아웃 후에는 세션 인증뿐 아니라 자동 로그인 인증도 더 이상 유지되지 않는다.


Remember-Me에서 가장 중요한 연결점은 로그인 화면 체크박스의 name 값과 SecurityConfig의 rememberMeParameter() 값이 같아야 한다는 점이다.


LoginForm은 로그인 입력 필드와 연결된다

LoginForm 코드

LoginForm은 로그인 화면의 입력 필드와 연결되는 폼 객체이다.
usernameInput은 계정 입력값이고, passwordInput은 암호 입력값이다.

// LoginForm.java
package com.example.security9.form;

import lombok.Data;

@Data
public class LoginForm {
    /** 사용자명 */
    private String usernameInput;

    /** 비밀번호 */
    private String passwordInput;
}

LoginForm은 인증을 직접 처리하지 않는다.
이 객체는 login.html에서 입력 필드를 만들 때 사용된다.


th:field="*{usernameInput}"을 사용하면 입력 필드의 name 값이 usernameInput으로 만들어진다.
th:field="*{passwordInput}"을 사용하면 입력 필드의 name 값이 passwordInput으로 만들어진다.


이 이름은 SecurityConfig의 usernameParameter("usernameInput"), passwordParameter("passwordInput")과 맞아야 한다.
이렇게 맞춰야 로그인 요청이 /authentication으로 전송되었을 때 Spring Security가 계정과 암호를 제대로 읽을 수 있다.


LoginController는 로그인 화면을 반환한다

LoginController 코드

LoginController는 /login 요청을 처리한다.
이 요청은 로그인하지 않은 사용자도 접근해야 하므로 SecurityConfig에서 permitAll()로 허용했다.

// LoginController.java
package com.example.security9.controller;

import com.example.security9.form.LoginForm;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class LoginController {

    @GetMapping("/login")
    public String showLogin(@ModelAttribute LoginForm form) {
        // login.html 화면을 반환한다.
        return "login";
    }
}

showLogin() 메서드는 login이라는 뷰 이름을 반환한다.
Spring MVC와 Thymeleaf는 이 값을 바탕으로 templates/login.html을 찾아 응답한다.


@ModelAttribute LoginForm form은 login.html에서 사용할 폼 객체를 준비한다.
이 객체가 있어야 th:object="${loginForm}"과 th:field를 이용해 입력 필드를 자연스럽게 연결할 수 있다.


여기서 중요한 점은 /login과 /authentication의 역할이 다르다는 것이다.
/login은 로그인 화면을 보여 주는 주소이다.
/authentication은 로그인 정보를 제출하는 주소이다.
/authentication 요청은 Controller가 아니라 Spring Security 인증 필터가 처리한다.


login.html에서 Remember-Me 체크박스 추가하기

login.html 코드

login.html은 사용자가 보는 로그인 화면이다.
기존 계정 입력칸과 암호 입력칸에 더해 자동 로그인 체크박스가 추가되어 있다.

<!-- login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>로그인</title>
</head>
<body>
    <h2>로그인 화면(RememberMe 기능 테스트)</h2>
    <!-- 로그인 실패 시 표시한다. -->
    <div th:if="${param.error}">
        <p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p>
    </div>
    <!-- 로그아웃 성공 시 표시한다. -->
    <div th:if="${param.logout}">
        <p style="color: blue">로그아웃했습니다.</p>
    </div>
    <form th:action="@{/authentication}" method="post" th:object="${loginForm}">
        <div>
            <label>계정</label>
            <input type="text" th:field="*{usernameInput}">
        </div>
        <div>
            <label>암호</label>
            <input type="password" th:field="*{passwordInput}">
        </div>

        <!-- Remember-Me 자동 로그인 체크박스이다. -->
        <div>
            <label>
                <input type="checkbox" name="remember-me">
                자동 로그인
            </label>
        </div>

        <hr>
        <div>
            <input type="submit" value="로그인">
        </div>
    </form>
</body>
</html>

th:action="@{/authentication}"은 로그인 폼을 /authentication 주소로 제출한다는 뜻이다.
이 주소는 SecurityConfig의 loginProcessingUrl("/authentication")과 같아야 한다.


th:field="*{usernameInput}"은 계정 입력칸을 LoginForm의 usernameInput 필드와 연결한다.
th:field="*{passwordInput}"은 암호 입력칸을 LoginForm의 passwordInput 필드와 연결한다.
이 두 이름은 SecurityConfig의 usernameParameter(), passwordParameter()와 맞아야 한다.


<input type="checkbox" name="remember-me">는 자동 로그인 체크박스이다.
여기서 name 값이 중요하다.
이 값이 SecurityConfig의 rememberMeParameter("remember-me")와 같아야 한다.
그래야 사용자가 체크박스를 선택했을 때 Spring Security가 자동 로그인 요청으로 인식할 수 있다.


th:if="${param.error}"는 로그인 실패 시 메시지를 보여준다.
로그인 실패 후 /login?error로 이동하기 때문에 error 파라미터가 있으면 실패 메시지가 표시된다.


th:if="${param.logout}"는 로그아웃 성공 시 메시지를 보여준다.
로그아웃 성공 후 /login?logout으로 이동하기 때문에 logout 파라미터가 있으면 로그아웃 메시지가 표시된다.

로그인 화면에는 계정 입력칸, 암호 입력칸, 자동 로그인 체크박스가 있다.
자동 로그인 체크박스의 name 값은 remember-me이고, 이 이름은 SecurityConfig의 rememberMeParameter("remember-me") 설정과 연결된다.
사용자가 이 체크박스를 선택하고 로그인하면 Spring Security는 자동 로그인에 사용할 remember-me 쿠키를 생성할 수 있다.


MemberController와 member.html 흐름

MemberController 코드

MemberController는 로그인 성공 후 이동할 / 요청을 처리한다.
SecurityConfig에서 defaultSuccessUrl("/", true)로 설정했기 때문에, 로그인에 성공하면 / 주소로 이동한다.

// MemberController.java
package com.example.security9.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MemberController {

    @GetMapping("/")
    public String member() {
        // member.html 화면을 반환한다.
        return "member";
    }
}

member() 메서드는 member라는 뷰 이름을 반환한다.
Spring MVC와 Thymeleaf는 templates/member.html을 찾아 응답한다.


/ 요청은 /login이 아니므로 인증이 필요하다.
따라서 로그인하지 않은 상태에서는 member.html을 볼 수 없다.
로그인에 성공했거나, Remember-Me로 인증 상태가 복원된 경우에만 회원 페이지를 볼 수 있다.


member.html 코드

member.html은 로그인 성공 후 보이는 회원 페이지이다.
이미지 링크와 로그아웃 버튼이 있다.

<!-- member.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>회원페이지</title>
    <style>
        a {
            text-decoration : none;
        }
    </style>
</head>
<body>
    <h2>로그인을 했군요!!</h2>
    <hr>
    <a href="/images/duke3d.png">로그인했으니 볼 수 있어요... 클릭해 보세요..</a><br>
    <hr>
    <button onclick="location.href='http://localhost:8080/logout'">로그아웃</button>
</body>
</html>

회원 페이지에는 /images/duke3d.png로 이동하는 링크가 있다.
현재 보안 설정에서는 /login만 공개되어 있다.
그 밖의 요청은 모두 인증이 필요하다.
따라서 /images/duke3d.png도 로그인 상태이거나 Remember-Me로 인증이 복원된 상태에서 접근할 수 있는 보호 리소스이다.


로그아웃 버튼은 http://localhost:8080/logout으로 이동한다.
현재 실습은 기본 포트 8080에서 실행하므로 이 주소로 로그아웃을 테스트할 수 있다.
또한 이 실습에서는 CSRF 방어 기능을 비활성화했기 때문에 버튼 클릭으로 발생하는 GET /logout 요청도 로그아웃 처리에 사용할 수 있다.
/logout 요청은 Controller가 처리하지 않는다.
Spring Security의 로그아웃 필터가 처리한다.
로그아웃에 성공하면 logoutSuccessUrl("/login?logout") 설정에 따라 로그인 화면으로 돌아간다.


Remember-Me 실행 흐름 확인

자동 로그인 체크 후 로그인했을 때의 흐름

브라우저에서 http://localhost:8080/login으로 접속한다.
계정에는 unico, 암호에는 1234를 입력한다.
그 다음 자동 로그인 체크박스를 선택하고 로그인 버튼을 누른다.


로그인 요청은 /authentication으로 전송된다.
이 요청은 LoginController가 처리하지 않는다.
Spring Security의 인증 필터가 계정과 암호를 꺼내 인증을 수행한다.


인증에 성공하면 defaultSuccessUrl("/", true) 설정에 따라 /로 이동한다.
/ 요청은 MemberController가 처리하고, 최종적으로 member.html이 응답된다.

사용자가 unico / 1234를 입력하고 자동 로그인을 체크한 뒤 로그인하면 인증이 성공한다.
로그인 성공 후에는 SecurityConfig의 defaultSuccessUrl("/", true) 설정에 따라 / 주소로 이동한다.
/ 요청은 MemberController가 처리하고, 최종적으로 member.html 회원 페이지가 응답된다.


로그인 성공 후 remember-me 쿠키가 만들어지는지 확인한다

자동 로그인을 체크하고 로그인에 성공하면 브라우저 쿠키 목록에서 remember-me 쿠키를 확인할 수 있다.
개발자 도구의 Application 탭에서 Cookies를 열고 http://localhost:8080을 선택하면 현재 브라우저에 저장된 쿠키 목록이 보인다.


일반 세션 로그인에서는 JSESSIONID 쿠키가 만들어진다.
Remember-Me를 체크하고 로그인하면 여기에 더해 remember-me 쿠키도 생성된다.
이 쿠키가 자동 로그인 복원에 사용된다.

자동 로그인을 체크하고 로그인하면 브라우저 쿠키 목록에 remember-me 쿠키가 생성된다.
일반 로그인 세션을 나타내는 JSESSIONID와 별도로 remember-me 쿠키가 만들어진다.
remember-me 쿠키는 세션이 사라진 뒤에도 사용자를 다시 인증된 상태로 복원할 때 사용된다.


세션이 사라진 뒤에도 Remember-Me로 다시 인증되는 흐름을 확인한다

Remember-Me가 실제로 동작하는지 확인하려면 JSESSIONID만 삭제해 보면 된다.
JSESSIONID는 일반 세션 로그인 상태를 찾기 위한 쿠키이다.
이 쿠키를 삭제하면 일반 세션 기준으로는 로그인 상태를 잃은 것처럼 볼 수 있다.


하지만 remember-me 쿠키가 남아 있으면 상황이 달라진다.
브라우저가 다시 보호된 페이지를 요청할 때 remember-me 쿠키를 함께 보내면, Spring Security가 이 쿠키를 확인한다.
쿠키가 유효하면 사용자를 다시 인증된 상태로 복원한다.
그리고 인증이 복원되는 과정에서 새로운 세션이 다시 만들어질 수 있다.


테스트 흐름은 다음과 같다.

  • 자동 로그인을 체크하고 로그인한다.
  • 개발자 도구에서 JSESSIONID 쿠키만 삭제한다.
  • remember-me 쿠키는 삭제하지 않는다.
  • / 페이지를 새로고침한다.
  • 로그인 화면으로 이동하지 않고 회원 페이지가 다시 열리는지 확인한다.

이 흐름이 성공하면 일반 세션 쿠키가 사라져도 Remember-Me로 인증이 복원된다는 뜻이다.
단순히 예전 세션이 살아 있는 것이 아니라, 남아 있는 remember-me 쿠키를 기준으로 사용자를 다시 인증하는 흐름이다.

JSESSIONID는 일반 세션 로그인 상태를 찾기 위한 쿠키이다.
이 값을 삭제하면 일반 세션 기준으로는 로그인 상태를 잃은 것처럼 볼 수 있다.
하지만 remember-me 쿠키가 남아 있으면 Spring Security가 이 쿠키를 확인해 사용자를 다시 인증된 상태로 복원한다.
그래서 JSESSIONID를 삭제한 뒤에도 보호된 회원 페이지에 다시 접근할 수 있다.


로그아웃하면 Remember-Me 인증 정보도 정리되는지 확인한다

마지막으로 로그아웃 흐름을 확인한다.
회원 페이지에서 로그아웃 버튼을 누르면 /logout 요청이 발생한다.
이 요청은 Spring Security 로그아웃 필터가 처리한다.


로그아웃은 단순히 화면만 이동시키는 기능이 아니다.
현재 인증 상태를 제거하고, 자동 로그인에 사용되는 정보도 정리하는 보안 처리이다.
로그아웃 후에는 logoutSuccessUrl("/login?logout") 설정에 따라 /login?logout으로 이동한다.


여기서 주의할 점은 코드에 직접 적힌 쿠키 삭제 설정은 .deleteCookies("JSESSIONID")라는 점이다.
이 설정은 세션 쿠키를 삭제한다.
그리고 rememberMe() 설정이 함께 적용되어 있기 때문에, 로그아웃 과정에서 Spring Security가 remember-me 쿠키도 무효화한다.
따라서 로그아웃 후에는 remember-me 쿠키로 다시 자동 인증되지 않는다.


login.html에는 th:if="${param.logout}" 조건이 있다.
그래서 주소에 logout 파라미터가 있으면 로그아웃했습니다. 메시지가 표시된다.

로그아웃은 단순히 화면만 로그인 페이지로 이동시키는 기능이 아니다.
현재 인증 상태를 제거하고, 자동 로그인에 사용되던 remember-me 정보도 함께 정리하는 보안 처리이다.
로그아웃 후에는 /login?logout으로 이동하고, 로그인 화면에서 로그아웃했습니다. 메시지를 확인할 수 있다.


security9 핵심 정리

security9에서는 PasswordEncoder 확인 예제와 Remember-Me 자동 로그인 기능을 함께 확인한다.
비밀번호는 흔히 암호화한다고 표현하지만, 실제 저장 흐름에서는 원본 비밀번호를 복호화할 수 없는 저장용 해시 문자열로 바꾸고 matches()로 비교한다.


웹 로그인 흐름에서는 SecurityConfig가 /login, /authentication, /logout 흐름을 설정한다.
/login은 로그인 화면을 보여 주는 주소이고, /authentication은 Spring Security가 로그인 인증을 처리하는 주소이다.
/logout은 로그아웃 필터가 처리하는 주소이다.


Remember-Me는 사용자가 자동 로그인 체크박스를 선택했을 때 동작한다.
login.html의 체크박스 name 값은 remember-me이고, SecurityConfig의 rememberMeParameter("remember-me")와 연결된다.
이 이름이 맞아야 자동 로그인 요청이 제대로 인식된다.


자동 로그인에 성공하면 브라우저에는 remember-me 쿠키가 생성된다.
일반 세션 쿠키인 JSESSIONID가 사라져도 remember-me 쿠키가 유효하면 Spring Security가 사용자를 다시 인증된 상태로 복원할 수 있다.
반대로 로그아웃을 하면 현재 세션 인증과 자동 로그인 정보가 정리되므로, remember-me 쿠키로 다시 인증되지 않는다.


security9의 핵심은 비밀번호는 PasswordEncoder로 저장용 해시 문자열을 만들고 비교하며, 자동 로그인은 remember-me 쿠키를 통해 세션이 사라진 뒤에도 인증 상태를 복원할 수 있다는 점이다.




인증 방식 종류: Cookie, Session, Token

웹에서 사용자가 한 번 로그인했다고 해서 서버가 자동으로 계속 그 사용자를 기억하는 것은 아니다.
웹 요청은 기본적으로 각각 독립적으로 처리된다.
사용자가 로그인 요청을 한 번 보내고, 그 다음에 마이페이지 요청을 보내면 서버 입장에서는 두 요청이 서로 다른 요청으로 들어온다.


그래서 서버는 “이 요청을 보낸 사람이 방금 로그인한 사람인가?”를 확인할 방법이 필요하다.
이때 함께 알아야 하는 대표 개념이 Cookie, Session, Token이다.
세 개념은 모두 사용자를 식별하기 위한 방법과 관련되어 있지만, 인증 정보를 어디에 저장하는지와 요청할 때 어떤 값을 서버에 보내는지가 다르다.


다만 처음부터 한 가지를 구분해야 한다.
Cookie는 그 자체로 완성된 인증 방식이라기보다, 값을 브라우저에 저장하고 서버로 다시 보내는 수단에 가깝다.
Session ID도 Cookie에 담겨 전달될 수 있고, Token도 상황에 따라 Cookie에 저장될 수 있다.


또 Token이라는 말도 넓게 사용된다.
어떤 토큰은 서버 저장소를 조회해야 의미를 확인할 수 있고, 어떤 토큰은 JWT처럼 토큰 자체에 사용자 정보와 서명 정보를 담아 서버가 검증할 수 있다.
이 글에서는 뒤에서 다룰 JWT를 이해하기 위해, 주로 서버가 토큰을 검증해서 사용자를 판단하는 흐름을 중심으로 정리한다.


Cookie, Session, Token의 차이는 로그인 상태를 누가 보관하는지, 그리고 요청마다 어떤 값을 서버에 보내는지로 이해하면 된다.


인증 상태를 유지해야 하는 이유

웹 요청은 기본적으로 독립적이다

웹에서 브라우저가 서버에 요청을 보낼 때, 서버는 요청 하나를 받아 처리하고 응답을 돌려준다.
그 다음에 또 다른 요청이 오면 서버는 새로운 요청으로 처리한다.


예를 들어 사용자가 로그인한 뒤 마이페이지로 이동한다고 생각하면 된다.
첫 번째 요청은 로그인 요청이다.
두 번째 요청은 마이페이지 요청이다.
이 두 요청은 서로 다른 요청이다.
서버가 아무런 식별 정보를 받지 못하면, 두 번째 요청이 방금 로그인한 사용자의 요청인지 알기 어렵다.


그래서 로그인 이후의 요청에도 사용자를 확인할 수 있는 값이 필요하다.
그 값이 Cookie일 수도 있고, Session ID일 수도 있고, Token일 수도 있다.


인증 방식은 사용자를 기억하는 방식의 차이이다

Cookie, Session, Token은 모두 사용자를 기억하기 위한 방식과 관련되어 있다.
하지만 기억하는 위치와 전달하는 값이 다르다.


Cookie는 브라우저가 값을 저장하고, 요청할 때 다시 서버로 보내는 방식이다.
Session은 서버가 로그인 상태를 저장하고, 브라우저는 그 세션을 찾기 위한 Session ID만 보내는 방식이다.
Token은 서버가 발급한 인증 문자열을 클라이언트가 저장하고, 이후 요청마다 그 토큰을 보내는 방식이다.


정리하면 다음과 같다.

  • Cookie는 브라우저에 저장되는 작은 정보이다.
  • Session은 서버에 저장되는 로그인 상태 정보이다.
  • Token은 인증된 사용자임을 증명하는 문자열이다.

세 개념은 서로 완전히 무관하지 않다.
예를 들어 Session ID는 보통 Cookie에 담겨 브라우저와 서버 사이를 오간다.
Token도 브라우저 저장소나 Cookie에 저장할 수 있다.
따라서 처음에는 저장 위치와 전달 흐름을 중심으로 구분하는 것이 좋다.


Cookie는 브라우저에 저장되는 작은 정보이다

Cookie는 서버가 브라우저에 저장시킬 수 있는 작은 문자열 정보이다.
보통 Key-Value 형태로 저장된다.
Key-Value는 이름과 값이 한 쌍으로 묶인 구조이다.
예를 들어 language=english처럼 language라는 이름에 english라는 값이 들어갈 수 있다.


브라우저는 서버가 내려준 Cookie를 저장해 둔다.
그리고 같은 서버로 다시 요청을 보낼 때 저장된 Cookie를 요청 헤더에 함께 담아 보낼 수 있다.
요청 헤더는 서버에 요청을 보낼 때 함께 전달되는 부가 정보라고 이해하면 된다.


여기서 중요한 점은 Cookie 자체가 항상 로그인 인증 방식이라는 뜻은 아니라는 점이다.
Cookie는 값을 저장하고 전달하는 수단이다.
그 안에 사용자의 언어 설정을 담을 수도 있고, 세션을 찾기 위한 Session ID를 담을 수도 있고, 상황에 따라 인증 토큰을 담을 수도 있다.

브라우저가 서버로 요청을 보낼 때 Request Headers 안에 cookie 값이 포함될 수 있다.
Cookie는 단순히 브라우저 안에만 저장되어 있는 값이 아니다.
같은 서버로 요청할 때 다시 서버에 전달될 수 있는 값이다.
서버는 요청에 포함된 Cookie를 보고 사용자의 이전 방문 정보, 언어 설정, 로그인 식별 정보 같은 상태를 이어서 처리할 수 있다.


Cookie는 Set-Cookie로 저장되고 Cookie로 다시 전달된다

처음 요청에서는 브라우저가 서버에 일반 요청을 보낸다.
서버는 응답을 돌려주면서 Set-Cookie 헤더에 값을 담아 보낼 수 있다.
Set-Cookie는 “브라우저야, 이 값을 저장해 둬”라는 의미로 볼 수 있다.


브라우저는 서버가 보낸 값을 저장한다.
그 다음 같은 서버로 다시 요청할 때는 저장해 둔 값을 Cookie 헤더에 담아 보낸다.
이 흐름 때문에 서버는 사용자가 이전에 어떤 값을 받았는지 확인할 수 있다.

처음에는 브라우저가 서버에 요청을 보낸다.
서버는 응답하면서 Set-Cookie에 값을 담아 브라우저에 저장시킨다.
이후 브라우저는 같은 서버로 다시 요청할 때 저장해 둔 Cookie 값을 요청에 함께 보낸다.
예를 들어 language=english 같은 값은 사용자의 언어 설정을 기억하는 데 사용할 수 있고, 세션 식별값은 서버가 사용자를 구분하는 데 사용할 수 있다.


Cookie는 브라우저가 자동으로 저장하고 요청마다 다시 보내 줄 수 있기 때문에 상태 유지에 편리하다.
사용자 언어 설정, 팝업 하루 동안 보지 않기, 장바구니 식별 정보처럼 여러 곳에서 사용할 수 있다.


하지만 Cookie는 클라이언트, 즉 브라우저 쪽에 저장된다.
클라이언트는 사용자가 직접 접근할 수 있는 환경이다.
그래서 민감한 정보를 그대로 넣으면 위험하다.


예를 들어 Cookie에 비밀번호나 주민등록번호 같은 값을 그대로 저장하면 안 된다.
브라우저에 저장된 값은 탈취될 수 있고, 개발자 도구 등을 통해 확인될 수도 있다.
또한 설정에 따라 조작 위험도 생길 수 있다.


Cookie는 상태 유지에 편리하지만, 브라우저에 저장되는 값이므로 민감한 정보를 그대로 담으면 안 된다.


Session 인증

Session은 서버가 로그인 상태를 저장하는 방식이다

Session은 서버가 로그인 상태를 저장하는 방식이다.
사용자가 로그인에 성공하면 서버는 “이 사용자는 로그인했다”는 정보를 서버 쪽 저장소에 만든다.
이 저장된 정보가 세션 정보이다.


브라우저에는 실제 로그인 정보 전체를 저장하지 않는다.
대신 서버에 있는 세션 정보를 찾기 위한 식별자만 보낸다.
이 식별자가 Session ID이다.
Spring 기반 웹 애플리케이션에서는 대표적으로 JSESSIONID라는 이름이 사용된다.


쉽게 말하면 서버에는 보관함이 있고, 브라우저는 그 보관함을 찾기 위한 번호표를 들고 있는 구조이다.
번호표가 Session ID이고, 실제 로그인 정보는 서버의 세션 저장소에 있다.

서버의 세션 저장소에는 JSESSIONID를 기준으로 여러 사용자의 세션 정보가 저장될 수 있다.
브라우저가 실제 사용자 정보를 전부 들고 다니는 것이 아니라, JSESSIONID를 서버에 보낸다.
서버는 이 값을 이용해 세션 저장소에서 해당 사용자의 creationTime, lastAccessedTime, userId 같은 정보를 찾는다.
이 흐름으로 서버는 요청을 보낸 사용자가 로그인한 사용자인지 판단할 수 있다.


Session 인증 흐름

사용자가 로그인하면 서버는 먼저 아이디와 비밀번호를 확인한다.
사용자가 맞다면 서버는 세션을 생성한다.
그리고 생성된 세션을 찾을 수 있는 Session ID를 브라우저에 전달한다.


브라우저는 이 Session ID를 보통 Cookie에 저장한다.
이후 요청을 보낼 때 브라우저는 Cookie에 담긴 Session ID를 함께 보낸다.
서버는 전달받은 Session ID를 이용해 세션 저장소를 조회한다.
세션이 존재하면 서버는 이 요청을 로그인한 사용자의 요청으로 판단할 수 있다.

사용자가 로그인 요청을 보내면 서버는 로그인 정보를 확인하고 세션을 생성한다.
서버는 생성된 세션을 유지하고, 브라우저에는 세션을 찾을 수 있는 값을 전달한다.
이후 브라우저가 요청을 보내면 서버는 세션 저장소에서 해당 세션을 읽는다.
즉, Session 방식의 핵심은 로그인 상태를 서버가 직접 보관한다는 점이다.


Session 방식의 장점과 단점

Session 방식의 장점은 민감한 인증 정보를 서버에 보관한다는 점이다.
브라우저에는 실제 사용자 정보 전체가 아니라 Session ID만 전달된다.
따라서 중요한 정보를 클라이언트에 직접 저장하는 방식보다 안전하게 관리할 수 있다.


하지만 단점도 있다.
서버가 로그인 상태를 저장해야 하므로 사용자 수가 많아질수록 세션 저장소 부담이 커질 수 있다.
서버가 여러 대로 늘어나는 구조에서는 모든 서버가 세션 정보를 공유해야 하는 문제도 생길 수 있다.


또한 Session ID가 탈취되면 문제가 생긴다.
공격자가 다른 사용자의 Session ID를 가지고 요청하면, 서버는 그 요청을 로그인한 사용자의 요청으로 오해할 수 있다.
그래서 Session ID도 안전하게 관리해야 한다.


정리하면 다음과 같다.

  • Session은 로그인 정보를 서버에 저장한다.
  • 브라우저는 Session ID만 들고 다닌다.
  • 민감한 정보를 서버에 둘 수 있다는 장점이 있다.
  • 서버 저장소 부담이 생긴다는 단점이 있다.
  • Session ID가 탈취되면 보안 문제가 생길 수 있다.

Session 방식은 전통적인 웹 로그인에서 많이 사용된다.
화면 중심 웹 애플리케이션에서는 이해하기 쉽고 구현 흐름도 자연스럽다.


Token 인증

Token은 인증된 사용자임을 증명하는 문자열이다

Token은 인증된 사용자임을 증명하는 문자열이다.
사용자가 로그인에 성공하면 서버는 토큰을 발급한다.
클라이언트는 이 토큰을 저장해 두고, 이후 요청마다 토큰을 함께 보낸다.


Session 방식은 서버가 로그인 상태를 저장한다.
반면 Token 방식은 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 사용할 수 있다.
특히 JWT처럼 서버가 자체적으로 검증할 수 있는 토큰을 사용하면, 서버는 요청에 포함된 토큰이 유효한지 확인해서 사용자를 판단할 수 있다.


여기서 “토큰을 확인한다”는 말은 단순히 문자열이 있는지만 본다는 뜻이 아니다.
서버는 토큰이 자신이 발급한 것인지, 중간에 위조되거나 변조되지 않았는지, 만료 시간이 지나지 않았는지 등을 검증한다.
특히 JWT 같은 토큰은 서버가 발급할 때 서명 정보를 함께 넣고, 이후 요청에서 전달된 토큰의 서명을 검증해 위조 여부를 확인할 수 있다.

Token 방식에서는 로그인 성공 후 서버가 토큰을 생성해 클라이언트에게 전달한다.
클라이언트는 이 토큰을 저장해 두고, 이후 데이터를 요청할 때 토큰을 함께 보낸다.
서버는 세션 저장소에서 로그인 상태를 찾는 대신 전달받은 토큰이 유효한지 검증한다.
토큰이 유효하면 서버는 요청을 보낸 사용자를 인증된 사용자로 판단하고 응답을 돌려준다.


Token은 신분증처럼 이해할 수 있다

Session ID와 Token은 둘 다 사용자를 식별하는 데 사용될 수 있다.
하지만 구조가 다르다.


Session ID는 서버의 세션 저장소를 찾기 위한 값이다.
그 자체만 보면 사용자 정보를 모두 알 수 있는 구조는 아니다.
서버가 세션 저장소를 조회해야 실제 사용자 정보를 확인할 수 있다.


반면 JWT처럼 자체 정보를 담는 Token은 사용자 식별 정보나 권한 정보 같은 내용을 담을 수 있다.
그래서 이런 토큰은 신분증에 비유할 수 있다.
신분증을 보면 이름이나 생년월일처럼 사용자를 판단할 수 있는 정보가 담겨 있다.
다만 신분증을 잃어버리면 다른 사람이 악용할 수 있는 것처럼, 토큰도 탈취되면 위험하다.


또 하나 주의할 점이 있다.
토큰 안에는 사용자 식별 정보나 권한 정보가 들어갈 수 있지만, 비밀번호 같은 민감한 정보를 그대로 넣으면 안 된다.
토큰은 클라이언트가 들고 다니는 값이므로 노출될 가능성을 항상 고려해야 한다.

Session ID는 서버의 세션 저장소를 조회해야 의미가 확인되는 값으로 볼 수 있다.
JWT처럼 자체 정보를 담는 Token은 토큰 자체에 사용자 식별 정보나 권한 정보가 들어갈 수 있다.
그래서 서버가 세션 저장소를 조회하지 않아도 인증 판단에 필요한 정보를 확인할 수 있다.
하지만 토큰 안의 내용은 노출될 수 있으므로 민감한 정보를 그대로 넣으면 안 된다.


Token 방식의 장점과 단점

Token 방식은 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 만들 수 있다는 장점이 있다.
이 구조는 서버를 여러 대로 늘리거나, 모바일 앱과 API 서버가 통신하는 구조에서 유리하다.


예를 들어 클라이언트가 React 앱이고 서버가 Spring Boot REST API 서버라면, 화면을 서버가 직접 만들어 주는 구조가 아니다.
클라이언트가 서버의 API를 계속 호출한다.
이런 구조에서는 요청마다 토큰을 담아 보내고, 서버가 토큰을 검증하는 방식이 잘 맞는다.


하지만 토큰 방식도 위험이 있다.
토큰은 클라이언트가 저장한다.
클라이언트 쪽에서 토큰이 탈취되면, 공격자가 그 토큰을 이용해 인증된 사용자처럼 요청을 보낼 수 있다.
또 이미 발급된 토큰을 즉시 무효화하기 어려운 경우도 있다.


그래서 토큰에는 만료 시간을 설정해야 한다.
또 토큰 안에는 비밀번호 같은 민감한 정보를 넣으면 안 된다.
Token은 서버 확장에는 유리하지만, 탈취되면 악용될 수 있으므로 저장 위치와 만료 시간 관리가 중요하다.


Session 기반 인증 절차

Session 방식은 서버가 로그인 상태를 기억한다

Session 기반 인증은 서버가 로그인 상태를 저장하는 구조이다.
사용자가 아이디와 비밀번호를 입력하면 서버는 회원 DB에서 사용자를 확인한다.
사용자가 맞으면 서버는 세션 저장소에 로그인 정보를 만들고, 브라우저에는 Session ID를 보내 준다.


이후 브라우저가 데이터를 요청할 때는 Cookie에 담긴 Session ID를 함께 보낸다.
서버는 이 값을 이용해 세션 저장소에서 사용자 정보를 찾는다.
사용자 정보가 확인되면 서버는 요청한 데이터를 응답한다.

Session 기반 인증에서는 사용자가 로그인하면 서버가 회원 DB에서 사용자를 확인한다.
사용자가 맞으면 서버는 세션 저장소에 회원 정보를 저장하고 Session ID를 발급한다.
브라우저는 이후 데이터를 요청할 때 Session ID가 담긴 Cookie를 함께 보낸다.
서버는 Cookie에서 Session ID를 확인하고, 세션 저장소에서 사용자 정보를 가져온 뒤 요청 데이터를 응답한다.


Session 인증 흐름 정리

Session 기반 인증 흐름을 단계별로 정리하면 다음과 같다.

  • 사용자가 아이디와 비밀번호로 로그인한다.
  • 서버는 회원 DB에서 사용자를 확인한다.
  • 사용자가 맞으면 세션 저장소에 회원 정보를 저장한다.
  • 서버는 Session ID를 발급한다.
  • 응답에 Session ID가 포함된다.
  • 브라우저는 이후 요청에 Cookie로 Session ID를 보낸다.
  • 서버는 Cookie에서 Session ID를 확인한다.
  • 서버는 세션 저장소에서 사용자 정보를 가져온다.
  • 서버는 요청 데이터를 응답한다.

이 구조에서 중요한 점은 서버가 로그인 상태를 기억한다는 것이다.
브라우저는 Session ID만 보내고, 실제 로그인 정보는 서버 세션 저장소에서 관리된다.


Token 기반 인증 절차

Token 방식은 클라이언트가 토큰을 들고 요청한다

Token 기반 인증은 서버가 발급한 토큰을 클라이언트가 저장하고, 이후 요청마다 함께 보내는 구조이다.
사용자가 로그인하면 서버는 회원 DB에서 사용자를 확인한다.
사용자가 맞으면 서버는 Access Token을 발급한다.
이 Access Token은 서비스 구조에 따라 JWT 형식일 수 있다.


클라이언트는 이 Access Token을 저장해 둔다.
이후 데이터를 요청할 때 Access Token을 함께 보낸다.
만약 Access Token이 JWT 형식이라면, 서버는 전달받은 JWT의 서명과 만료 시간 등을 검증한다.
토큰이 유효하면 요청 데이터를 응답한다.


이때 서버는 단순히 토큰 문자열이 존재하는지만 보는 것이 아니다.
토큰이 서버가 발급한 형식과 맞는지, 서명이 올바른지, 만료 시간이 지나지 않았는지 등을 확인한다.
이 검증을 통과해야 인증된 요청으로 처리된다.

Token 기반 인증에서는 사용자가 로그인하면 서버가 회원 DB에서 사용자를 확인하고 Access Token을 발급한다.
클라이언트는 이 토큰을 저장해 두었다가 이후 데이터를 요청할 때 함께 보낸다.
서버는 전달받은 토큰을 검증하고, 유효한 토큰이면 요청한 데이터를 응답한다.
이 방식은 서버가 세션 저장소에 로그인 상태를 계속 저장하지 않아도 되는 구조로 만들 수 있다는 특징이 있다.


Token 인증 흐름 정리

Token 기반 인증 흐름을 단계별로 정리하면 다음과 같다.

  • 사용자가 아이디와 비밀번호로 로그인한다.
  • 서버는 회원 DB에서 사용자를 확인한다.
  • 사용자가 맞으면 Access Token을 발급한다.
  • 서버는 Access Token을 응답으로 보낸다.
  • 클라이언트는 Access Token을 저장한다.
  • 이후 요청마다 Access Token을 함께 보낸다.
  • 서버는 Access Token의 유효성을 검증한다.
  • 토큰이 유효하면 요청 데이터를 응답한다.

Session 방식과 달리 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 만들 수 있다.
대신 서버는 요청마다 전달받은 토큰을 검증해서 사용자를 판단한다.


Session 기반 인증과 Token 기반 인증 비교

두 방식은 로그인 상태를 보관하는 위치가 다르다

Session 방식과 Token 방식은 모두 로그인한 사용자를 식별하기 위한 방식이다.
하지만 가장 큰 차이는 로그인 상태를 어디에 두는가이다.


Session 방식은 서버가 로그인 상태를 저장한다.
브라우저는 Session ID만 보내고, 서버가 세션 저장소에서 사용자 정보를 찾는다.


Token 방식은 클라이언트가 인증 토큰을 들고 다닌다.
서버는 세션 저장소를 조회하는 대신 전달받은 토큰이 유효한지 검증한다.


차이를 정리하면 다음과 같다.

  • Session 방식은 서버가 로그인 상태를 저장한다.
  • Token 방식은 클라이언트가 인증 토큰을 저장한다.
  • Session 방식은 서버 세션 저장소 조회가 필요하다.
  • Token 방식은 토큰 검증으로 사용자를 판단한다.
  • Session ID는 서버의 세션을 찾는 값이다.
  • Token은 인증에 사용할 수 있는 증명 값이다.

두 방식 중 하나가 무조건 더 좋다고 단정할 수는 없다.
화면 중심 웹 서비스에서는 Session 방식이 자연스러울 수 있고, API 중심 서비스나 모바일 앱에서는 Token 방식이 더 적합할 수 있다.


Session은 서버가 로그인 상태를 기억하는 방식이고, Token은 클라이언트가 인증 증명 값을 들고 다니는 방식이다.


인증 방식 종류 핵심 정리

Cookie는 브라우저에 저장되는 작은 정보이다.
서버는 Set-Cookie로 값을 내려주고, 브라우저는 이후 요청마다 Cookie를 다시 보낼 수 있다.
다만 Cookie 자체가 항상 완성된 인증 방식이라는 뜻은 아니며, Session ID나 Token 같은 인증 관련 값을 저장하고 전달하는 수단으로 사용될 수 있다.


Session은 서버가 로그인 상태를 저장하는 방식이다.
브라우저는 Session ID를 Cookie로 보내고, 서버는 이 값을 이용해 세션 저장소에서 사용자 정보를 찾는다.


Token은 인증된 사용자임을 증명하는 문자열이다.
서버는 로그인 성공 후 토큰을 발급하고, 클라이언트는 이후 요청마다 토큰을 함께 보낸다.
서버는 세션 저장소를 조회하는 대신 토큰의 유효성과 위조 여부를 검증할 수 있다.
특히 JWT처럼 자체 검증 가능한 토큰은 서명과 만료 시간을 확인해 인증 여부를 판단할 수 있다.


Session 방식은 민감한 로그인 상태를 서버가 관리할 수 있다는 장점이 있지만, 서버 저장소 부담이 생길 수 있다.
Token 방식은 서버 확장에 유리하지만, 토큰이 탈취되면 위험할 수 있다.


JWT를 이해하려면 먼저 Cookie, Session, Token이 각각 어떻게 사용자를 식별하고 인증 상태를 유지하는지 알아야 한다.




JWT 기반 인증이 사용되는 예

JWT는 로그인한 사용자를 서버가 매 요청마다 세션 저장소에서 찾지 않아도, 요청에 담긴 토큰을 검증해 사용자를 확인할 수 있게 해 주는 인증 토큰 형식이다.
토큰은 사용자가 로그인에 성공했을 때 서버가 발급하는 인증용 문자열이다.
클라이언트는 이 토큰을 저장해 두었다가, 이후 요청마다 서버에 함께 보낸다.


이 방식은 화면을 서버가 직접 만들어 주는 전통적인 웹 구조보다, 프론트엔드와 백엔드가 분리된 구조에서 자주 사용된다.
예를 들어 React 화면이 따로 있고, Spring Boot 서버는 API만 제공하는 구조라면 요청마다 사용자를 확인할 수 있는 값이 필요하다.
이때 클라이언트가 JWT를 요청 Header에 담아 보내면 서버는 토큰의 서명, 만료 시간, 사용자 정보를 확인해 요청한 사용자를 판단할 수 있다.


JWT는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 조회하지 않고, 클라이언트가 보낸 토큰을 검증해서 사용자를 확인하는 구조에서 주로 사용된다.


REST API 서버 인증

REST API는 데이터나 기능을 제공하는 통로이다

REST API는 클라이언트가 서버의 데이터를 요청하거나, 서버의 기능을 사용하기 위해 호출하는 주소 구조이다.
예를 들어 게시글 목록을 가져오거나, 회원 정보를 조회하거나, 주문을 등록하는 요청이 API 요청이 될 수 있다.


이때 모든 사용자가 아무 데이터나 가져가면 안 된다.
로그인한 사용자만 볼 수 있는 정보가 있고, 관리자만 사용할 수 있는 기능도 있다.
그래서 REST API 서버는 요청을 보낸 사용자가 정당한 사용자인지 확인해야 한다.


JWT를 사용하는 흐름은 다음과 같다.

  • 사용자가 아이디와 비밀번호로 로그인한다.
  • 서버는 사용자가 맞는지 확인한다.
  • 로그인에 성공하면 서버가 JWT를 발급한다.
  • 클라이언트는 이후 API 요청마다 JWT를 함께 보낸다.
  • 서버는 JWT를 검증한 뒤 데이터를 응답한다.

이 구조에서는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 찾지 않아도 된다.
요청에 포함된 JWT가 유효한지 검증해서 사용자를 판단할 수 있기 때문이다.


실제 요청에서는 보통 Authorization 헤더에 Bearer 토큰값 형태로 담아 보낸다.
예를 들어 클라이언트가 주문 목록을 요청한다면 요청 헤더에는 아래와 같은 값이 포함될 수 있다.

Authorization: Bearer JWT토큰값

서버는 이 헤더에서 토큰을 꺼내 검증한 뒤, 요청을 보낸 사용자가 누구인지 판단한다.


예를 들어 /api/orders가 주문 목록을 가져오는 주소라고 생각하면 된다.
사용자가 로그인 후 받은 JWT를 함께 보내면 서버는 이 토큰을 확인하고, 해당 사용자의 주문 목록만 응답할 수 있다.
토큰이 없거나 잘못되면 서버는 주문 목록을 응답하지 않는다.


SPA 인증

SPA는 프론트엔드와 백엔드가 분리된 구조에서 자주 사용된다

SPA는 Single Page Application의 줄임말이다.
처음 한 번 화면을 불러온 뒤, 필요한 데이터만 서버에 요청하면서 화면을 바꾸는 방식이다.
React, Vue, Angular 같은 프론트엔드 기술로 만든 서비스에서 자주 사용된다.


SPA에서는 서버가 매번 완성된 HTML 화면을 만들어 보내는 방식과 다르다.
프론트엔드가 화면을 담당하고, 백엔드는 JSON 데이터나 기능만 API로 제공하는 경우가 많다.
그래서 로그인 상태를 화면 서버가 세션으로 관리하는 방식보다, 프론트엔드가 토큰을 저장하고 요청마다 보내는 방식이 자연스럽다.


SPA에서의 JWT 흐름은 다음과 같다.

  • 사용자가 프론트엔드 로그인 화면에서 로그인한다.
  • 프론트엔드는 로그인 요청을 백엔드 API로 보낸다.
  • 백엔드는 사용자를 확인하고 JWT를 발급한다.
  • 프론트엔드는 발급받은 JWT를 저장한다.
  • 이후 게시글 조회, 회원 정보 조회 같은 요청마다 JWT를 첨부한다.
  • Spring Boot 같은 백엔드 서버는 JWT를 검증하고 응답한다.

이 구조에서는 프론트엔드와 백엔드가 서로 다른 서버에서 동작해도 인증 흐름을 유지할 수 있다.
클라이언트가 요청마다 토큰을 보내기 때문이다.


다만 JWT를 어디에 저장할지는 신중하게 정해야 한다.
localStorage에 저장하면 사용하기는 쉽지만, 스크립트 공격에 노출될 수 있다.
HttpOnly Cookie에 저장하면 자바스크립트에서 직접 읽기 어렵게 만들 수 있지만, 쿠키 보안 설정을 함께 고려해야 한다.


즉, JWT를 저장하는 위치는 정답이 하나로 고정되어 있지 않다.
프론트엔드 구조, 보안 요구사항, 서버의 쿠키 설정 방식에 따라 달라지므로 저장 위치를 정할 때는 토큰 탈취 위험을 함께 고려해야 한다.


모바일 앱 인증

모바일 앱은 API 호출 중심으로 동작한다

모바일 앱은 웹 브라우저처럼 서버가 내려준 화면을 그대로 보여주는 방식보다, 앱 안에서 화면을 직접 구성하고 서버의 API를 호출하는 방식이 많다.
예를 들어 앱에서 로그인하고, 내 정보 조회 버튼을 누르고, 주문 내역을 가져오는 모든 과정이 서버 API 호출로 이루어질 수 있다.


이 구조에서는 로그인 후 서버가 Access Token을 발급하고, 앱은 이 토큰을 저장한다.
이후 앱이 서버에 요청할 때마다 Authorization 헤더에 토큰을 담아 보낸다.
서버는 토큰을 검증하고 사용자를 확인한다.


모바일 앱에서 JWT가 사용되는 흐름은 다음과 같다.

  • 사용자가 앱에서 로그인한다.
  • 서버가 사용자 정보를 확인한다.
  • 서버가 Access Token을 발급한다.
  • 앱은 토큰을 내부 저장소에 보관한다.
  • 앱이 데이터를 요청할 때 토큰을 함께 보낸다.
  • 서버는 토큰을 검증하고 응답한다.

앱은 토큰을 내부 저장소에 보관한다.
다만 토큰은 인증에 사용되는 값이므로, 실제 서비스에서는 가능한 한 안전한 저장 공간에 보관해야 한다.


모바일 앱은 브라우저 세션에 의존하기 어렵다.
그래서 요청마다 사용자를 증명할 수 있는 토큰 방식이 잘 맞는다.


예를 들어 사용자가 쇼핑 앱에서 내 주문 목록을 조회한다고 생각하면 된다.
앱은 서버에 주문 목록 요청을 보내면서 JWT를 함께 보낸다.
서버는 토큰을 보고 “이 요청은 로그인한 사용자 요청이다”라고 판단한 뒤 해당 사용자의 주문 목록을 응답한다.


마이크로서비스 구조

여러 서비스가 나뉜 환경에서도 JWT가 사용될 수 있다

마이크로서비스 구조는 하나의 큰 서버가 모든 기능을 처리하는 방식이 아니다.
회원 서비스, 주문 서비스, 결제 서비스, 알림 서비스처럼 기능별로 서버가 나뉘는 구조이다.


이런 구조에서는 사용자의 인증 정보를 여러 서비스가 함께 확인해야 할 수 있다.
예를 들어 사용자가 주문을 요청하면 주문 서비스는 이 사용자가 누구인지 알아야 한다.
결제 서비스도 같은 사용자의 권한과 정보를 확인해야 할 수 있다.


JWT는 이런 구조에서 인증 정보를 전달하는 데 사용할 수 있다.
JWT 안에 사용자 ID, 권한, 만료 시간 같은 정보를 담을 수 있기 때문이다.
각 서비스는 전달받은 토큰을 직접 검증하거나, API Gateway에서 검증된 사용자 정보를 전달받아 요청을 처리할 수 있다.


흐름은 다음과 같다.

  • 사용자가 로그인한다.
  • 인증 서버가 JWT를 발급한다.
  • 사용자의 요청은 API Gateway를 거쳐 각 서비스로 전달된다.
  • API Gateway 또는 각 서비스는 요청에 포함된 JWT를 검증한다.
  • 토큰이 유효하면 사용자 정보를 바탕으로 요청을 처리한다.

API Gateway는 여러 서비스 앞에서 요청을 받아 적절한 서비스로 보내는 입구 역할을 한다.
이 구조에서는 사용자 인증 정보를 한 서비스 안에만 묶어 두기보다, 검증 가능한 토큰으로 전달하는 방식이 유용할 수 있다.


OAuth2, 소셜 로그인, SSO

외부 인증 서버가 발급한 토큰을 사용하는 구조에서도 JWT가 사용될 수 있다

OAuth2는 외부 서비스의 인증 권한을 이용해 로그인하거나 자원 접근 권한을 위임받는 방식이다.
구글 로그인, 카카오 로그인, 네이버 로그인 같은 소셜 로그인에서 자주 등장한다.


소셜 로그인에서는 사용자가 직접 우리 서버에 아이디와 비밀번호를 입력하지 않을 수 있다.
대신 외부 인증 서버가 사용자를 확인하고, 인증 결과로 토큰을 발급한다.
이때 발급되는 Access Token이나 ID Token이 JWT 형식일 수 있다.


다만 모든 Access Token이 반드시 JWT 형식인 것은 아니다.
외부 인증 서버에 따라 토큰 형식이 다를 수 있고, 어떤 토큰은 외부 인증 서버에 다시 확인해야 의미를 알 수 있다.
따라서 JWT는 여러 토큰 형식 중 하나로 이해해야 한다.


SSO는 Single Sign-On의 줄임말이다.
한 번 로그인하면 여러 서비스에 다시 로그인하지 않고 접근할 수 있게 하는 방식이다.
회사 내부 시스템처럼 여러 서비스가 연결된 환경에서 자주 사용된다.


이런 구조에서 JWT가 사용되는 이유는 다음과 같다.

  • 외부 인증 서버가 사용자 정보를 검증한다.
  • 인증 결과를 토큰 형태로 발급한다.
  • 다른 서비스는 토큰을 검증해 사용자를 확인한다.
  • 토큰 안의 사용자 정보나 권한 정보를 기반으로 접근을 제어할 수 있다.

즉, JWT는 우리 서버가 직접 로그인 화면을 처리하는 경우뿐 아니라, 외부 인증 시스템과 연결되는 구조에서도 사용될 수 있다.


권한 기반 API 접근 제어

JWT는 로그인 여부뿐 아니라 권한 처리에도 사용될 수 있다

서비스에서는 단순히 “로그인했는가?”만 확인하면 부족할 때가 많다.
어떤 기능은 일반 사용자도 사용할 수 있지만, 어떤 기능은 관리자만 사용할 수 있다.
이때 필요한 개념이 권한이다.


JWT에는 사용자 식별 정보뿐 아니라 권한 정보를 담을 수 있다.
예를 들어 role 값에 USER 또는 ADMIN을 넣거나, 권한 목록을 담아 둘 수 있다.
서버는 토큰을 검증한 뒤 이 권한 정보를 읽고, 해당 사용자가 요청한 기능을 사용할 수 있는지 판단할 수 있다.


권한 기반 접근 제어 흐름은 다음과 같다.

  • 사용자가 로그인한다.
  • 서버는 사용자의 권한을 확인한다.
  • 서버는 권한 정보를 포함한 JWT를 발급한다.
  • 클라이언트는 요청마다 JWT를 보낸다.
  • 서버는 토큰에서 권한 정보를 꺼낸다.
  • 요청한 기능에 필요한 권한과 비교한다.
  • 권한이 맞으면 요청을 허용하고, 맞지 않으면 거부한다.

예를 들어 관리자 페이지는 ADMIN 권한이 있는 사용자만 접근해야 한다.
일반 사용자가 관리자 페이지를 요청하면 서버는 토큰의 권한을 확인하고 접근을 막을 수 있다.


Spring Security에서는 @PreAuthorize 같은 방식으로 메서드 실행 전에 권한을 검사할 수 있다.
이때 인증 객체 안에 들어 있는 권한 정보가 접근 허용 여부를 결정하는 기준이 된다.


JWT가 잘 맞는 상황

서버와 클라이언트가 분리된 구조에서 특히 많이 사용된다

JWT는 모든 상황에서 무조건 써야 하는 기술은 아니다.
전통적인 서버 렌더링 웹사이트에서는 Session 방식만으로도 충분할 수 있다.
하지만 서버와 클라이언트가 분리되고, 요청마다 인증 정보를 함께 보내야 하는 구조에서는 JWT가 잘 맞는다.


JWT가 잘 맞는 상황은 다음과 같다.

  • 프론트엔드와 백엔드가 분리된 SPA 구조
  • 모바일 앱과 API 서버 구조
  • REST API 인증 구조
  • 서버를 여러 대로 확장해야 하는 구조
  • 마이크로서비스 사이에서 인증 정보를 전달해야 하는 구조
  • OAuth2, 소셜 로그인, SSO와 연동하는 구조
  • 사용자 권한에 따라 API 접근을 제어해야 하는 구조

다만 JWT는 편리한 만큼 보안 관리가 중요하다.
토큰이 탈취되면 만료되기 전까지 악용될 수 있기 때문이다.
그래서 만료 시간, 저장 위치, 재발급 방식, 민감 정보 포함 여부를 함께 고려해야 한다.


JWT는 서버 확장성과 분리된 클라이언트 구조에 잘 맞지만, 토큰 저장과 만료 관리까지 함께 설계해야 안전하게 사용할 수 있다.


JWT 사용 예 핵심 정리

JWT는 서버가 로그인 상태를 세션 저장소에 계속 보관하지 않고, 클라이언트가 가진 토큰을 검증해서 사용자를 확인하는 방식에서 자주 사용된다.


REST API, SPA, 모바일 앱, 마이크로서비스, 소셜 로그인, SSO, 권한 기반 접근 제어처럼 요청마다 인증 정보를 함께 보내야 하는 구조에 잘 맞는다.


하지만 JWT는 토큰 자체가 인증에 사용할 수 있는 증명 값이기 때문에 탈취되면 위험하다.
그래서 토큰 안에 민감한 정보를 넣지 않고, 만료 시간과 저장 위치를 안전하게 설계해야 한다.


JWT는 단순히 로그인 성공 후 받는 문자열이 아니라, 분리된 서비스 구조에서 사용자를 증명하고 권한을 확인하기 위한 인증 수단이다.




JWT 기본 구조와 다양한 인증 타입

JWT는 겉으로 보면 점이 들어간 긴 문자열처럼 보인다.
하지만 실제로는 아무 의미 없는 문자열이 아니라, 정해진 구조를 가진 인증 토큰 형식이다.
JWT는 Header, Payload, Signature 세 부분으로 나뉘고, 각 부분은 점(.)으로 구분된다.


이 구간은 JWT 내부 구조를 먼저 이해하고, 토큰이 실제 요청에서 어떤 헤더에 담겨 전달되는지까지 이어진다.
따라서 Header, Payload, Signature를 이해한 다음 Authorization 헤더와 다양한 인증 타입을 함께 정리한다.


여기서 처음부터 구분해야 할 점이 있다.
JWT 내부의 Header는 토큰 자체의 구조와 서명 알고리즘을 설명하는 영역이다.
반면 요청 헤더의 Authorization은 클라이언트가 서버로 인증 정보를 보내는 위치이다.
둘 다 Header라는 말이 나오지만, 서로 다른 개념이다.


JWT는 Header, Payload, Signature 세 부분으로 구성되고, 실제 요청에서는 보통 Authorization 헤더에 담겨 서버로 전달된다.


JWT의 의미

JWT는 JSON Web Token의 줄임말이다

JWT는 JSON Web Token의 줄임말이다.
JSON은 데이터를 key-value 형태로 표현하는 방식이다.
Web Token은 웹에서 인증 정보를 전달하기 위해 사용하는 토큰이라고 이해하면 된다.


쉽게 말하면 JWT는 로그인한 사용자를 확인하기 위한 정보를 일정한 구조로 담은 문자열이다.
사용자가 로그인에 성공하면 서버는 JWT를 발급할 수 있다.
클라이언트는 이 토큰을 저장해 두었다가, 이후 요청마다 서버에 함께 보낸다.


서버는 요청에 포함된 JWT를 보고 사용자를 판단한다.
다만 여기서 서버가 아무 문자열이나 믿는 것은 아니다.
서버는 토큰의 서명, 만료 시간, 필요한 사용자 정보를 확인해서 유효한 토큰인지 검증한다.
이 검증을 통과한 뒤에야 토큰 안의 사용자 정보를 신뢰할 수 있다.


JWT는 단순한 랜덤 문자열이 아니라, 사용자 정보와 검증 정보를 정해진 구조로 담은 인증 토큰 형식이다.


JWT 구조

JWT는 Header, Payload, Signature로 구성된다

JWT는 크게 세 부분으로 나뉜다.
각 부분은 역할이 다르다.


Header는 토큰 자체에 대한 정보를 담는다.
이 토큰이 어떤 타입인지, 어떤 알고리즘으로 서명했는지 같은 정보가 들어간다.


Payload는 토큰에 담을 실제 데이터를 가진다.
사용자 식별 정보, 권한, 발급 시간, 만료 시간 같은 값이 들어갈 수 있다.


Signature는 토큰이 중간에 조작되었는지 확인하기 위한 서명 값이다.
서버의 비밀 키나 개인키를 이용해 만들어지므로, 토큰을 신뢰할 수 있는지 판단할 때 사용된다.

JWT는 Header, Payload, Signature 세 부분으로 구성된다.
Header에는 토큰 타입과 서명 알고리즘 정보가 들어간다.
Payload에는 사용자 식별 정보나 권한 같은 실제 데이터가 들어간다.
Signature는 Header와 Payload를 기준으로 서버의 비밀 키나 개인키를 사용해 만든 값이며, 토큰이 중간에 조작되었는지 확인하는 기준이 된다.


세 부분은 점으로 연결된다

실제 JWT 문자열은 세 부분이 점(.)으로 연결된 형태이다.
형식은 Header.Payload.Signature로 이해하면 된다.


첫 번째 구역은 Header이다.
두 번째 구역은 Payload이다.
세 번째 구역은 Signature이다.


겉으로 보면 하나의 긴 문자열처럼 보인다.
하지만 점을 기준으로 나누면 각각 다른 역할을 가진 세 구역으로 분리된다.
그래서 JWT를 디코딩하는 도구에 넣으면 Header와 Payload 내용을 확인할 수 있다.


다만 디코딩과 검증은 다르다.
디코딩은 토큰 안의 내용을 읽어 보는 과정이다.
검증은 이 토큰이 서버가 발급한 정상 토큰인지, 중간에 조작되지 않았는지, 만료되지 않았는지 확인하는 과정이다.


따라서 Header와 Payload를 디코딩해서 볼 수 있다고 해서 그 토큰을 바로 믿으면 안 된다.
서버는 반드시 Signature와 만료 시간을 검증한 뒤 토큰을 신뢰해야 한다.

실제 JWT 문자열은 Header.Payload.Signature 형태로 이어진다.
각 부분은 점으로 구분된다.
첫 번째 문자열은 토큰 정보, 두 번째 문자열은 사용자 정보와 클레임, 세 번째 문자열은 서명 검증 값을 의미한다.


Header와 Payload는 암호화가 아니라 인코딩된다

JWT의 Header와 Payload는 보통 Base64 URL-safe 방식으로 인코딩된다.
인코딩은 데이터를 다른 문자 형태로 바꾸는 처리이다.
암호화처럼 내용을 숨기는 처리가 아니다.


따라서 Header와 Payload는 디코딩하면 내용을 확인할 수 있다.
예를 들어 Payload에 사용자 ID, 권한, 만료 시간 같은 값이 들어 있다면 디코딩 도구로 그 값을 볼 수 있다.


이 점 때문에 Payload에는 비밀번호, 주민등록번호, 카드번호 같은 민감한 정보를 넣으면 안 된다.
JWT는 서명으로 조작 여부를 확인할 수 있지만, Header와 Payload 내용을 숨겨 주는 구조는 아니다.


JWT의 Header와 Payload는 숨겨진 비밀 공간이 아니다.
디코딩하면 내용을 볼 수 있으므로, 비밀번호나 주민등록번호 같은 민감한 값은 절대 넣으면 안 된다.


Header는 토큰 자체에 대한 정보를 담는다

Header는 JWT의 첫 번째 부분이다.
토큰의 내용 자체보다는, 이 토큰이 어떤 방식으로 만들어졌는지를 설명하는 정보가 들어간다.


쉽게 말하면 Header는 “이 토큰은 어떤 종류의 토큰이고, 어떤 방식으로 서명되었는가?”를 알려 주는 영역이다.
그래서 Header에는 사용자 이름이나 권한 같은 실제 사용자 정보가 들어가는 것이 아니다.
사용자 식별 정보, 권한, 만료 시간 같은 실제 데이터는 주로 Payload에 들어간다.


Header에서 자주 보는 값은 typ와 alg이다.
typ는 토큰의 타입을 나타내고, alg는 서명을 만들 때 사용할 알고리즘을 나타낸다.


typ는 토큰의 타입을 나타낸다

typ는 type의 줄임말로 볼 수 있다.
말 그대로 이 토큰이 어떤 타입의 토큰인지 알려 주는 값이다.


JWT에서는 보통 아래처럼 typ 값으로 JWT가 들어간다.

{
  "typ": "JWT"
}

이 값은 “이 토큰은 JWT 형식의 토큰이다”라는 의미이다.


즉, typ는 사용자 정보가 아니다.
토큰을 해석하는 쪽에서 “아, 이건 JWT 형식으로 보면 되는구나”라고 알 수 있게 해 주는 안내 정보에 가깝다.


alg는 서명 알고리즘을 나타낸다

alg는 algorithm의 줄임말이다.
여기서는 JWT의 Signature를 만들 때 어떤 서명 알고리즘을 사용할지 나타낸다.


JWT는 Header와 Payload를 그냥 붙여서 끝내는 구조가 아니다.
서버는 Header와 Payload를 기준으로 Signature를 만든다.
이때 어떤 방식으로 서명값을 만들 것인지 나타내는 값이 alg이다.


예를 들어 Header가 아래처럼 되어 있다고 하자.

{
  "typ": "JWT",
  "alg": "HS512"
}

여기서 typ는 이 토큰이 JWT 형식이라는 뜻이다.
alg는 이 토큰의 서명을 만들 때 HS512 알고리즘을 사용한다는 뜻이다.


다만 검증할 때는 토큰의 Header에 적힌 alg를 무조건 믿으면 안 된다.
서버는 자신이 허용한 알고리즘인지 확인하고, 그 알고리즘에 맞는 키로 서명을 검증해야 한다.


typ는 토큰의 종류를 나타내고, alg는 토큰의 서명을 만드는 알고리즘을 나타낸다.


HS256과 HS512는 Secret Key를 함께 쓰는 방식이다

HS256과 HS512에서 HS는 HMAC 계열의 서명 방식을 의미한다고 이해하면 된다.
이 방식은 서버가 알고 있는 하나의 Secret Key를 사용해 서명값을 만들고 검증한다.


쉽게 말하면 서버가 비밀 재료 하나를 가지고 있다고 보면 된다.
서버는 토큰을 만들 때 이 Secret Key로 Signature를 만든다.
나중에 토큰이 다시 들어오면 같은 Secret Key로 다시 검증한다.


HS256과 HS512의 차이는 사용하는 해시 함수와 출력 크기이다.

HS256 = HMAC + SHA-256 기반 서명 방식
HS512 = HMAC + SHA-512 기반 서명 방식

숫자가 붙어 있다고 해서 사용자 권한이나 토큰 만료 시간 같은 값이 아니다.
256, 512는 서명 알고리즘에서 사용하는 해시 출력 크기와 관련된 값이다.


정리하면 HS256과 HS512는 아래처럼 이해하면 된다.

서명 대상: 인코딩된 Header + "." + 인코딩된 Payload
서명 생성: 서명 대상 + Secret Key -> HMAC-SHA 기반 알고리즘 적용 -> Signature 생성
서명 검증: 들어온 토큰의 서명 대상 + 같은 Secret Key -> Signature가 맞는지 확인

이 방식에서는 Secret Key가 매우 중요하다.
Secret Key가 유출되면 공격자가 정상처럼 보이는 토큰을 만들 수 있기 때문이다.


RS256은 개인키와 공개키를 나누어 쓰는 방식이다

RS256에서 RS는 RSA 계열의 서명 방식을 의미한다고 이해하면 된다.
HS256, HS512와 다르게 RS256은 하나의 Secret Key만 사용하는 방식이 아니다.


RS256은 개인키와 공개키를 나누어 사용한다.
서버는 개인키로 토큰에 서명한다.
그리고 토큰을 검증하는 쪽은 공개키로 서명이 올바른지 확인할 수 있다.


흐름은 아래처럼 볼 수 있다.

서명 대상: 인코딩된 Header + "." + 인코딩된 Payload
서명 생성: 서명 대상 + 개인키 -> RSA-SHA-256 기반 알고리즘 적용 -> Signature 생성
서명 검증: 들어온 토큰의 서명 대상 + 공개키 -> Signature가 맞는지 확인

개인키는 토큰을 발급하는 서버가 안전하게 보관해야 한다.
공개키는 토큰을 검증해야 하는 다른 서버나 서비스에 공유할 수 있다.


그래서 여러 서비스가 같은 토큰을 검증해야 하는 구조에서는 RS256 같은 공개키 기반 방식이 사용될 수 있다.
예를 들어 인증 서버가 개인키로 JWT를 발급하고, 다른 서비스들은 공개키로 이 토큰이 진짜 인증 서버가 만든 토큰인지 확인할 수 있다.


HS256, HS512, RS256은 사용자 정보가 아니라 서명 방식이다

HS256, HS512, RS256은 사용자 아이디, 권한, 만료 시간 같은 값이 아니다.
이 값들은 모두 JWT의 Signature를 만들고 검증하는 방식을 나타내는 알고리즘 이름이다.


정리하면 다음과 같다.

  • typ: 이 토큰이 어떤 타입인지 나타낸다.
  • alg: 이 토큰의 서명을 어떤 알고리즘으로 만들었는지 나타낸다.
  • HS256: Secret Key를 사용하는 HMAC + SHA-256 기반 서명 방식이다.
  • HS512: Secret Key를 사용하는 HMAC + SHA-512 기반 서명 방식이다.
  • RS256: 개인키로 서명하고 공개키로 검증하는 RSA + SHA-256 기반 서명 방식이다.

즉, Header는 토큰을 어떻게 해석하고 검증할지 알려 주는 안내 정보이다.
사용자 정보 자체는 Header가 아니라 주로 Payload에 들어간다.


여기서 중요한 점은 alg와 뒤에서 나오는 Authorization 헤더의 인증 타입은 다르다는 것이다.
alg는 JWT 내부의 서명 알고리즘이다.
반면 Authorization의 <type>은 인증 정보를 어떤 방식으로 보낼지 알려 주는 인증 타입이다.

Header는 토큰 자체에 대한 정보를 담는다.
typ는 토큰의 종류를 나타내고, alg는 서명을 만들 때 사용할 알고리즘을 나타낸다.
예를 들어 typ가 JWT이면 이 토큰이 JWT 형식이라는 뜻이다.
alg가 HS512이면 해당 알고리즘을 사용해 서명을 만든다는 뜻이다.


Header는 사용자 정보를 담는 공간이 아니다.
사용자 이름, 권한, 만료 시간처럼 실제 인증에 필요한 데이터는 주로 Payload에 들어간다.
Header는 토큰을 어떻게 해석하고 검증할지 알려 주는 안내 정보에 가깝다.


Payload

Payload는 토큰에 담을 실제 데이터를 가진다

Payload는 JWT의 두 번째 부분이다.
여기에는 토큰에 담을 실제 데이터가 들어간다.
이 데이터를 보통 Claim이라고 부른다.


Claim은 토큰 안에 담긴 정보 조각이라고 이해하면 된다.
Claim은 key-value 형태로 이루어진 정보이다.
예를 들어 sub라는 이름에 1234567890이라는 값이 들어가면, 이것이 하나의 Claim이다.


대표적으로 볼 수 있는 Payload 값은 다음과 같다.

  • sub: 토큰의 주체를 나타낸다.
  • name: 사용자 이름 같은 값을 담을 수 있다.
  • iat: 토큰이 발급된 시간을 나타낸다.
  • exp: 토큰이 만료되는 시간을 나타낸다.
  • role: 사용자의 권한을 나타내는 값으로 사용할 수 있다.

예를 들어 사용자가 로그인하면 서버는 Payload에 사용자 식별값과 권한, 만료 시간을 담은 JWT를 만들 수 있다.
이후 클라이언트가 이 토큰을 보내면 서버는 먼저 토큰의 서명과 만료 시간을 검증한다.
검증이 통과되면 서버는 Payload의 사용자 정보를 기준으로 요청한 사용자를 판단할 수 있다.


다만 Payload는 디코딩하면 볼 수 있는 영역이다.
그래서 Payload에는 비밀번호 같은 민감한 정보를 넣으면 안 된다.
사용자 식별값이나 권한처럼 서버가 인증 판단에 사용할 수 있는 정보만 필요한 만큼 담아야 한다.

Payload에는 토큰에 담을 실제 정보가 들어간다.
이 정보는 key-value 형태로 작성되며, 한 쌍의 정보를 Claim이라고 부른다.
예를 들어 sub, name, iat 같은 값은 각각 사용자 식별 정보, 사용자 이름, 발급 시간처럼 토큰에서 필요한 정보를 나타낸다.


여기서 가장 주의해야 할 점은 Payload가 암호화된 공간이 아니라는 것이다.
Payload는 Base64 URL-safe 방식으로 인코딩될 뿐, 비밀스럽게 암호화되는 것이 아니다.
따라서 디코딩하면 내용을 볼 수 있다.


예를 들어 Payload에 role: ADMIN이 들어 있다면, 토큰을 디코딩했을 때 그 값이 보일 수 있다.
그래서 권한처럼 서버가 검증에 사용할 정보는 넣을 수 있지만, 비밀번호처럼 노출되면 안 되는 값은 넣으면 안 된다.


Payload에는 사용자 식별 정보와 권한 정보를 담을 수 있지만, 민감한 비밀 정보는 넣으면 안 된다.


Claim 종류

Claim은 목적에 따라 나눌 수 있다

Claim은 Payload 안에 들어가는 정보이다.
JWT에서는 이 Claim을 목적에 따라 나눌 수 있다.
대표적으로 Registered Claim, Public Claim, Private Claim이 있다.


Registered Claim은 미리 정의된 표준 클레임이다.
반드시 모두 써야 하는 것은 아니지만, 의미가 정해져 있기 때문에 자주 사용된다.
예를 들어 iss, exp, sub, iat, jti가 있다.


각각의 의미는 다음과 같다.

  • iss: 토큰 발행자를 의미한다.
  • exp: 토큰 만료 시간을 의미한다.
  • sub: 토큰의 주체를 의미한다. 보통 사용자를 구분하는 값으로 사용된다.
  • iat: 토큰 발행 시간을 의미한다.
  • jti: 토큰의 고유 식별자를 의미한다.

Public Claim은 공개적으로 사용할 수 있는 정보 전달용 클레임이다.
충돌이 나지 않도록 이름을 신중하게 정해야 한다.
여기서 충돌은 다른 시스템이나 다른 개발자가 같은 이름을 다른 의미로 사용하는 상황을 뜻한다.


Private Claim은 특정 당사자끼리 정보를 공유하기 위해 만든 사용자 지정 클레임이다.
예를 들어 우리 서버와 우리 프론트엔드 사이에서만 쓰기로 약속한 role, level, teamId 같은 값이 여기에 해당할 수 있다.

Claim은 목적에 따라 나뉜다.
Registered Claim은 iss, exp, sub, iat, jti처럼 미리 정해진 표준 이름이다.
Public Claim은 공개적으로 사용할 수 있는 정보 전달용 클레임이다.
Private Claim은 서버와 클라이언트처럼 특정 당사자끼리 필요한 정보를 공유하기 위해 정의하는 클레임이다.


여기서 중요한 점은 Private Claim이라고 해서 자동으로 안전하게 숨겨지는 정보가 아니라는 것이다.
이름이 Private일 뿐, Payload에 들어간 값은 디코딩하면 볼 수 있다.
따라서 Private Claim에도 민감한 정보를 그대로 넣으면 안 된다.


Signature

Signature는 토큰이 조작되지 않았는지 확인하는 값이다

Signature는 JWT의 세 번째 부분이다.
Signature는 Header와 Payload를 기준으로 만들어지는 서명 값이다.


쉽게 말하면 Signature는 토큰 내용이 중간에 바뀌었는지 확인하는 검증 값이다.
누군가 Payload의 권한을 USER에서 ADMIN으로 바꾸면, 원래의 Signature와 맞지 않게 된다.
서버는 이 차이를 보고 토큰이 조작되었다고 판단할 수 있다.


Signature는 보통 아래 재료를 이용해 만든다.

  • 인코딩된 Header
  • 인코딩된 Payload
  • 서버가 알고 있는 Secret Key 또는 개인키

흐름을 단순하게 보면 아래와 같다.

인코딩된 Header + "." + 인코딩된 Payload
+ Secret Key 또는 개인키
-> 서버가 선택한 서명 알고리즘 적용
-> Signature 생성

JWT에서 실제 서명 대상은 인코딩된 Header와 인코딩된 Payload를 점(.)으로 연결한 값이다.
여기에 서버가 알고 있는 Secret Key 또는 개인키를 사용해 서명 알고리즘을 적용하면 Signature가 만들어진다.
이렇게 만들어진 Signature는 토큰의 앞 두 부분이 바뀌지 않았는지 확인하는 기준이 된다.


서버는 요청으로 들어온 토큰을 검증할 때 서버가 허용한 알고리즘과 키를 기준으로 서명을 확인한다.
검증 결과가 맞으면 토큰이 조작되지 않았다고 판단할 수 있다.
검증 결과가 맞지 않으면 토큰을 신뢰하지 않고 인증 실패로 처리한다.


여기서 중요한 점은 Signature가 Header와 Payload를 숨겨 주는 기능이 아니라는 점이다.
Signature는 내용을 감추는 값이 아니라, 내용이 바뀌었는지 확인하는 값이다.


그래서 Secret Key 또는 개인키는 코드에 그대로 노출하지 않고 안전하게 관리해야 한다.
Secret Key나 개인키가 유출되면 공격자가 정상처럼 보이는 토큰을 만들 수 있기 때문이다.

Signature는 Header와 Payload를 Base64Url 방식으로 인코딩한 값에 서버의 비밀 키를 더해 만든다.
서버는 클라이언트가 보낸 토큰을 다시 같은 방식으로 계산해 보고, 토큰 안의 Signature와 비교한다.
두 값이 다르면 Header나 Payload가 중간에 바뀐 것으로 판단할 수 있다.


Header와 Payload는 토큰 안에 들어 있는 데이터 영역이다.
Signature는 이 데이터가 중간에 바뀌지 않았는지 확인하는 검증 영역이다.
이 세 부분이 함께 있어야 서버가 토큰을 신뢰할 수 있다.


JWT 구조 정리

JWT 문자열은 세 구역으로 나뉜다

JWT 문자열은 Header, Payload, Signature 순서로 구성된다.
이 세 부분은 점으로 구분된다.


첫 번째 Header는 알고리즘 정보를 담는다.
두 번째 Payload는 사용자 정보와 권한 정보를 담을 수 있다.
세 번째 Signature는 토큰이 조작되었는지 확인한다.


Header와 Payload는 디코딩하면 내용을 볼 수 있다.
그래서 Payload에는 민감한 정보를 넣으면 안 된다.
반면 Signature는 서버가 토큰을 신뢰할 수 있는지 확인하는 기준이다.
Signature가 맞지 않으면 토큰 내용이 중간에 바뀐 것으로 판단할 수 있다.

JWT 문자열은 점을 기준으로 세 구역으로 나뉜다.
첫 번째 구역은 Header, 두 번째 구역은 Payload, 세 번째 구역은 Signature이다.
Header에는 토큰 타입과 알고리즘 정보가 들어가고, Payload에는 사용자 식별 정보나 발급 시간 같은 클레임이 들어간다.
Signature는 Header와 Payload가 중간에 조작되지 않았는지 확인하는 검증 값이다.


이 구조를 알면 이후 JwtExam1 코드도 쉽게 이어진다.
setSubject()는 Payload에 sub 값을 넣는 흐름이고, setIssuedAt()은 iat 값을 넣는 흐름이다.
setExpiration()은 exp 값을 넣는 흐름이고, signWith()는 Signature를 만들기 위한 서명 흐름이다.
마지막 compact()는 이 정보를 최종 JWT 문자열로 조립하는 흐름이다.


Authorization 헤더

Authorization 헤더는 인증 정보를 담는 요청 헤더이다

JWT는 보통 요청 본문에 넣기보다 Authorization 헤더에 담아 보낸다.
요청 본문은 요청 데이터가 들어가는 영역이고, 요청 헤더는 요청에 대한 부가 정보가 들어가는 영역이다.
인증 정보는 요청 자체를 누가 보냈는지 판단하는 데 필요하므로, 보통 Authorization 헤더에 담는다.


형식은 아래처럼 이해하면 된다.


Authorization: <type> <credentials>


여기서 <type>은 인증 타입을 의미한다.
<credentials>는 실제 인증 정보를 의미한다.
JWT를 사용할 때는 보통 <type> 자리에 Bearer를 넣고, <credentials> 자리에 토큰 값을 넣는다.

Authorization: Bearer <JWT값>

이 형식에서 Bearer는 인증 타입이고, <JWT값>은 실제 인증 정보이다.
서버는 Authorization 헤더를 읽고, Bearer 뒤에 있는 토큰 값을 꺼내 검증한다.

토큰은 요청 헤더의 Authorization 필드에 담아 서버로 보낼 수 있다.
형식은 Authorization: <type> <credentials>이다.
type은 인증 타입이고, credentials는 실제 인증 정보이다.
JWT 인증에서는 보통 type에 Bearer를 사용하고, credentials 자리에 토큰 값을 넣는다.


다양한 인증 타입

Authorization 헤더의 type에 따라 인증 방식이 달라진다

Authorization 헤더의 <type> 자리에는 여러 인증 타입이 들어갈 수 있다.
JWT에서는 보통 Bearer를 사용하지만, Authorization 헤더가 항상 Bearer만 사용하는 것은 아니다.


대표적인 인증 타입은 다음과 같다.

  • Basic: 사용자 아이디와 암호를 Base64로 인코딩한 값을 사용한다. 단, Base64는 암호화가 아니므로 값을 쉽게 디코딩할 수 있고, 단독으로 안전한 보호 방식이라고 보면 안 된다.
  • Bearer: JWT 형식의 토큰이나 OAuth2 Access Token을 사용한다.
  • Digest: 서버가 보낸 난수 값과 사용자 정보를 조합한 해시값을 사용한다.
  • HOBA: 전자 서명 기반 인증 방식이다.
  • Mutual: 클라이언트와 서버가 서로를 인증하는 방식이다.
  • AWS4-HMAC-SHA256: AWS에서 사용하는 전자 서명 기반 인증 방식이다.

이 구간에서 중요한 것은 모든 인증 타입을 외우는 것이 아니다.
Authorization 헤더는 인증 정보를 담는 공통 위치이고, <type> 값에 따라 서버가 인증 정보를 해석하는 방식이 달라진다는 점을 이해하면 된다.


여기서 Authorization 헤더의 인증 타입과 JWT Header의 alg는 다르다.
Authorization의 Bearer는 “토큰을 어떤 방식으로 보낼 것인가”에 대한 인증 타입이다.
Header의 alg는 “토큰의 서명을 어떤 알고리즘으로 만들 것인가”에 대한 정보이다.


Bearer는 인증 타입이고, alg는 서명 알고리즘이다.
두 개를 같은 개념으로 보면 안 된다.


Bearer Token

Bearer는 토큰을 가진 요청자라는 의미로 이해한다

Bearer는 “이 토큰을 가진 요청자”라는 의미로 이해하면 된다.
Authorization: Bearer <JWT값> 형태로 요청이 오면 서버는 Bearer 뒤에 있는 실제 토큰 값을 꺼낸다.


만약 Bearer 뒤의 토큰이 JWT 형식이라면 서버는 토큰의 Signature와 만료 시간을 검증한다.
토큰이 유효하면 인증된 사용자로 처리할 수 있다.
토큰이 없거나, Bearer 형식이 아니거나, 서명이 맞지 않거나, 만료되었다면 인증 실패로 처리할 수 있다.


여기서 중요한 점은 Bearer 방식이 토큰을 가진 요청자를 기준으로 인증한다는 점이다.
즉, 토큰을 가진 사람이 인증 정보를 함께 보낸 것으로 보고 서버가 검증을 진행한다.
따라서 토큰이 탈취되면 공격자도 그 토큰을 이용해 인증된 사용자처럼 요청을 보낼 수 있다.
그래서 Bearer Token은 저장 위치, 만료 시간, 전송 구간 보안을 함께 고려해야 한다.


이 흐름은 이후 security10의 AuthController2, AuthController3과 연결된다.
AuthController2는 응답 헤더의 Authorization에 Bearer 토큰을 담아 내려준다.
AuthController3은 요청 헤더의 Authorization 값을 읽고, Bearer 뒤의 실제 JWT만 추출해 토큰 유효성과 사용자 인증 정보를 확인한다.


JWT 기본 구조와 다양한 인증 타입 핵심 정리

JWT는 Header, Payload, Signature 세 부분으로 구성된다.
세 부분은 점으로 연결되어 하나의 긴 문자열처럼 보인다.


Header는 토큰 타입과 서명 알고리즘 정보를 담는다.
Payload는 사용자 식별 정보, 권한, 발급 시간, 만료 시간 같은 실제 데이터를 담을 수 있다.
Signature는 서버의 비밀 키나 개인키를 이용해 만들며, 토큰이 중간에 조작되었는지 확인하는 기준이 된다.


Header와 Payload는 Base64 URL-safe 방식으로 인코딩된다.
인코딩은 암호화가 아니므로 디코딩하면 내용을 확인할 수 있다.
그래서 Payload에는 비밀번호 같은 민감한 값을 절대 넣지 않는다.


JWT에서 디코딩과 검증은 다르다.
디코딩은 토큰 내용을 읽어 보는 과정이고, 검증은 토큰을 신뢰할 수 있는지 확인하는 과정이다.
서버는 Signature와 만료 시간을 검증한 뒤에야 Payload의 사용자 정보를 신뢰할 수 있다.


Authorization 헤더는 인증 정보를 담는 공통 위치이다.
JWT를 사용할 때는 보통 Authorization: Bearer <JWT값> 형식으로 전달한다.
다만 Authorization 헤더의 인증 타입에는 Basic, Bearer, Digest, HOBA, Mutual, AWS4-HMAC-SHA256 같은 여러 방식이 들어갈 수 있다.


JWT를 이해할 때는 내부 구조인 Header, Payload, Signature와 요청 전달 방식인 Authorization 헤더를 구분해서 봐야 한다.




security10: JWT 생성과 검증 실습

security10은 JWT를 직접 생성하고, 생성된 토큰을 검증한 뒤, Access Token과 Refresh Token을 함께 만들어 보는 실습이다.
앞에서 JWT의 구조와 사용 이유를 개념으로 정리했다면, 이번에는 실제 Java 코드에서 JWT가 어떤 값으로 만들어지는지 확인한다.


이 실습에서 가장 중요한 흐름은 세 가지이다.
첫째, JwtExam1에서 하나의 JWT를 생성한다.
둘째, JwtValidator와 JwtExam2에서 생성된 토큰이 유효한지 검증한다.
셋째, JwtExam3에서 Access Token과 Refresh Token을 함께 발급한다.


앞에서 JWT는 Header, Payload, Signature 세 부분으로 구성된다고 정리했다.
security10에서는 이 구조가 실제 코드에서 어떻게 만들어지는지 확인한다.
setSubject(), setIssuedAt(), setExpiration()은 주로 Payload에 들어갈 값을 설정하는 흐름이고, signWith()는 Signature를 만들기 위한 서명 흐름이다.


security10의 핵심은 JWT가 코드에서 어떻게 생성되고, 어떤 기준으로 검증되며, Access Token과 Refresh Token을 왜 나누어 사용하는지 이해하는 것이다.


security10에서 확인할 전체 흐름

JWT 생성과 검증을 코드로 직접 확인한다

JWT는 단순한 임의 문자열이 아니다.
Header, Payload, Signature가 정해진 규칙으로 합쳐진 문자열이다.
그래서 코드에서 어떤 값을 넣느냐에 따라 토큰 안에 들어가는 정보와 만료 시간이 달라진다.


security10에서는 먼저 JwtExam1로 토큰을 만든다.
이때 Payload에는 sub, iat, exp, level 같은 값이 들어간다.
그리고 signWith()를 통해 Secret Key와 HS256 알고리즘으로 Signature를 만든다.
이때 HS256은 앞에서 설명한 JWT Header의 alg 값과 연결되는 서명 알고리즘이다.


그 다음 JWT Debugger로 생성된 토큰을 디코딩해 Header, Payload, Signature 구조를 확인한다.
여기서 디코딩은 토큰 내용을 읽어 보는 과정이다.
검증은 토큰이 서버가 발급한 정상 토큰인지, 서명이 맞는지, 만료되지 않았는지 확인하는 과정이다.
따라서 디코딩과 검증은 반드시 구분해서 봐야 한다.


마지막으로 JwtValidator와 JwtExam2에서 생성된 토큰이 유효한지 검증한다.
이때 검증에는 토큰 구조 확인, Signature 검증, 만료 시간 확인이 포함된다.
이후 JwtExam3에서는 실제 인증 흐름에서 자주 사용하는 Access Token과 Refresh Token을 함께 만든다.


전체 흐름은 다음과 같다.

  • JwtExam1에서 JWT를 생성한다.
  • 생성된 JWT를 JWT Debugger에서 디코딩해 구조를 확인한다.
  • JwtValidator에서 토큰 구조, 서명, 만료 시간을 검증한다.
  • JwtExam2에서 JwtValidator를 호출해 검증 결과를 확인한다.
  • JwtExam3에서 Access Token과 Refresh Token을 함께 생성한다.
  • 두 토큰을 디코딩해 sub, iat, exp를 확인하고, 특히 exp 값의 차이를 비교한다.

이 흐름을 보면 JWT는 생성만 중요한 것이 아니라, 서버가 다시 검증할 수 있어야 의미가 있다는 점을 알 수 있다.


security10 의존성 설정 확인

JWT 라이브러리가 있어야 토큰 생성과 검증 코드를 사용할 수 있다

security10에서 JWT를 생성하고 검증하려면 관련 라이브러리가 먼저 필요하다.
Jwts, Claims, SignatureAlgorithm, Keys 같은 클래스는 기본 Java 문법이 아니라 JWT 라이브러리에서 제공하는 기능이다.


그래서 build.gradle에 jjwt 관련 의존성이 추가되어 있어야 한다.
이 설정이 빠지면 JwtExam1, JwtValidator, JwtExam3 코드에서 import io.jsonwebtoken...으로 시작하는 클래스를 사용할 수 없다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    compileOnly 'org.projectlombok:lombok'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.5'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

jjwt-api는 JWT를 만들고 파싱할 때 사용하는 기본 기능을 제공한다.
jjwt-impl은 실제 동작 구현을 담당한다.
jjwt-jackson은 JWT 안의 JSON 데이터를 처리할 때 사용된다.


즉, 이 의존성들이 있어야 Jwts.builder(), Jwts.parser(), Claims, Keys.hmacShaKeyFor() 같은 코드를 사용할 수 있다.


JwtExam1에서 JWT 생성하기

Secret Key로 서명된 JWT를 만든다

JwtExam1은 하나의 JWT를 생성하는 예제이다.
이 코드에서 가장 중요한 값은 SECRET_KEY이다.
SECRET_KEY는 토큰의 Signature를 만들 때 사용하는 비밀 키이다.


앞에서 HS256은 Secret Key 하나를 사용해 서명값을 만들고 검증하는 방식이라고 정리했다.
JwtExam1도 같은 구조이다.
토큰을 만들 때 SECRET_KEY로 Signature를 만들고, 나중에 검증할 때도 같은 SECRET_KEY를 사용해야 한다.


여기서 같은 SECRET_KEY를 사용한다는 말은 같은 비밀 키 문자열을 기준으로 서명과 검증을 한다는 뜻이다.
JwtExam1에서는 SECRET_KEY.getBytes()로 키에 사용할 바이트 배열을 만들고, JwtValidator에서는 SECRET_KEY.getBytes(StandardCharsets.UTF_8)로 키에 사용할 바이트 배열을 만든다.
코드 형태는 조금 다르지만 핵심은 토큰을 만들 때 사용한 비밀 키 문자열과 검증할 때 사용하는 비밀 키 문자열이 같아야 한다는 점이다.


Signature는 토큰이 조작되지 않았는지 확인하는 값이다.
서버는 토큰을 만들 때 사용한 비밀 키와 같은 키로 나중에 토큰을 검증한다.
키가 다르면 서버가 다시 계산한 서명과 토큰 안의 서명이 달라져서 검증에 실패한다.

// JwtExam1.java
package com.example.security10.app;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

public class JwtExam1 {

    // JWT 서명에 사용할 비밀 키이다.
    private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret";

    // 비밀 키 문자열을 서명에 사용할 Key 객체로 바꾼다.
    private Key getSigningKey() {
        byte[] keyBytes = SECRET_KEY.getBytes();
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(String subject) {
        // 현재 시간을 밀리초 단위로 구한다.
        long nowMillis = System.currentTimeMillis();
        // 현재 시간에서 1시간 뒤를 만료 시간으로 정한다.
        long expMillis = nowMillis + 1000 * 60 * 60; // 1시간 만료

        // Payload에 추가로 넣을 클레임을 만든다.
        Map<String, Object> claimMap = new HashMap<String, Object>();
        claimMap.put("level", "gold");

        return Jwts.builder()
                // 직접 만든 클레임 맵을 Payload에 넣는다.
                .setClaims(claimMap)
                // 토큰의 주체를 sub 클레임에 넣는다.
                .setSubject(subject)
                // 토큰 발급 시간을 iat 클레임에 넣는다.
                .setIssuedAt(new Date(nowMillis))
                // 토큰 만료 시간을 exp 클레임에 넣는다.
                .setExpiration(new Date(expMillis))
                // 비밀 키와 HS256 알고리즘으로 서명한다.
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                // 최종 JWT 문자열을 만든다.
                .compact();
    }

    public static void main(String[] args) {
        // JWT 생성 객체를 만든다.
        JwtExam1 jwtUtil = new JwtExam1();

        // unico123을 subject로 넣어 JWT를 생성한다.
        String token = jwtUtil.generateToken("unico123");

        // 생성된 JWT를 콘솔에 출력한다.
        System.out.println("생성된 JWT 토큰:");
        System.out.println(token);
    }
}

generateToken("unico123")을 실행하면 sub 값에 unico123이 들어간다.
sub는 토큰의 주체를 의미한다.
이 단계에서는 사용자를 구분하는 값이라고 이해하면 된다.


claimMap.put("level", "gold")는 Payload에 추가 정보를 넣는 부분이다.
즉, 이 토큰을 디코딩하면 level 값으로 gold가 들어간 것을 확인할 수 있다.


setIssuedAt()은 토큰 발급 시간을 넣는다.
iat 클레임과 연결된다.
setExpiration()은 토큰 만료 시간을 넣는다.
exp 클레임과 연결된다.
이 코드에서는 현재 시간에서 1시간 뒤를 만료 시간으로 설정한다.


signWith(getSigningKey(), SignatureAlgorithm.HS256)은 토큰에 서명하는 부분이다.
앞에서 정리한 JWT Header의 alg 값과 연결되는 부분이다.
이 코드에서는 HS256 알고리즘과 SECRET_KEY를 이용해 Signature를 만든다.


마지막 compact()는 지금까지 설정한 Header, Payload, Signature 정보를 하나의 JWT 문자열로 조립한다.
그래서 최종 결과는 Header.Payload.Signature 형태의 긴 문자열로 만들어진다.

JwtExam1을 실행하면 콘솔에 생성된 JWT 문자열이 출력된다.
이 문자열은 Header, Payload, Signature가 점(.)으로 연결된 한 줄 문자열이다.
이 토큰을 복사해서 디코딩하거나 검증할 수 있다.


생성된 JWT 구조 확인하기

생성된 JWT는 JWT Debugger 같은 도구에서 디코딩할 수 있다.
디코딩은 토큰 안의 Header와 Payload 내용을 사람이 읽을 수 있게 풀어 보는 과정이다.


여기서 주의할 점은 디코딩과 검증이 다르다는 것이다.
디코딩은 토큰 안의 내용을 확인하는 것이다.
검증은 토큰이 조작되지 않았는지, 만료되지 않았는지, 서버가 신뢰할 수 있는 서명인지 확인하는 것이다.


JWT Debugger에 토큰을 넣으면 Header와 Payload 내용을 볼 수 있다.
Header에는 alg 값으로 HS256이 들어간다.
이 값은 JwtExam1의 signWith(getSigningKey(), SignatureAlgorithm.HS256) 코드와 연결된다.
즉, 이 토큰은 HS256 알고리즘과 Secret Key를 사용해 서명된 토큰이다.


Payload에는 level, sub, iat, exp 같은 클레임이 들어간다.
각 값은 아래처럼 이해하면 된다.

  • level: claimMap.put("level", "gold")로 직접 추가한 사용자 지정 클레임이다.
  • sub: 토큰의 주체를 의미한다. 이 예제에서는 generateToken("unico123")로 전달한 unico123이 들어간다.
  • iat: 토큰이 발급된 시간을 의미한다. 코드의 setIssuedAt(new Date(nowMillis))와 연결된다.
  • exp: 토큰이 만료되는 시간을 의미한다. 코드의 setExpiration(new Date(expMillis))와 연결된다.

이 흐름을 코드와 연결해서 보면 더 직관적이다.
claimMap.put("level", "gold")는 Payload에 level 값을 추가한다.
setSubject(subject)는 Payload에 sub 값을 넣는다.
setIssuedAt()은 iat 값을 넣고, setExpiration()은 exp 값을 넣는다.


예를 들어 generateToken("unico123")을 실행하면 Payload는 아래와 같은 의미를 가진다.

level = gold
sub = unico123
iat = 토큰 발급 시간
exp = 토큰 만료 시간

level은 직접 만든 추가 정보이고, sub, iat, exp는 JWT에서 자주 사용하는 클레임이다.
그래서 디코딩 결과를 볼 때는 단순히 값이 보인다고 넘어가지 말고, 이 값들이 코드의 어느 부분에서 만들어졌는지 함께 봐야 한다.


다만 JWT Debugger에서 Invalid Signature가 보일 수 있다.
이것은 Header와 Payload를 읽지 못한다는 뜻이 아니다.
토큰 구조는 디코딩해서 볼 수 있지만, 서명 검증은 코드에서 사용한 Secret Key와 같은 키를 입력해야 성공할 수 있다는 뜻이다.
따라서 이 화면에서는 우선 Header와 Payload 구조 확인에 집중하면 된다.

생성된 JWT를 디코딩하면 Header, Payload, Signature가 분리되어 보인다.
Header에는 alg 값으로 HS256이 들어가고, Payload에는 level, sub, iat, exp 같은 클레임이 들어간다.
level은 코드에서 직접 추가한 사용자 지정 클레임이고, sub는 토큰의 주체, iat는 발급 시간, exp는 만료 시간을 의미한다.
이 값들은 암호화되어 숨겨진 값이 아니라 인코딩된 값이므로 디코딩해서 확인할 수 있다.
그래서 Payload에는 민감한 정보를 넣으면 안 된다.


JWT Debugger에서 Invalid Signature가 보이더라도 이 화면은 토큰의 Header와 Payload 구조를 확인하기 위한 결과물로 볼 수 있다.
서명 검증까지 성공시키려면 오른쪽의 비밀 키 입력 영역에 코드에서 사용한 Secret Key와 같은 값을 넣어야 한다.


JwtValidator와 JwtExam2로 JWT 검증하기

JwtValidator는 토큰이 유효한지 검사한다

JwtValidator는 전달받은 JWT가 정상적인 토큰인지 확인하는 역할을 한다.
토큰 검증은 단순히 문자열이 있는지 보는 것이 아니다.
토큰 구조가 맞는지, 서명이 맞는지, 만료 시간이 지나지 않았는지 확인한다.


여기서 검증에 사용하는 SECRET_KEY는 JwtExam1에서 토큰을 만들 때 사용한 SECRET_KEY와 같아야 한다.
HS256 방식은 같은 Secret Key로 서명 생성과 서명 검증을 하기 때문이다.
만약 생성할 때 사용한 키와 검증할 때 사용하는 키가 다르면 Signature 검증에 실패한다.


검증 흐름은 다음과 같다.

  • parser()로 토큰을 해석할 준비를 한다.
  • setSigningKey()로 검증에 사용할 비밀 키를 지정한다.
  • build()로 파서를 완성한다.
  • parseClaimsJws()로 토큰 구조, 서명, 만료 시간을 검증한다.
  • 토큰이 만료되면 ExpiredJwtException이 발생한다.
  • 서명이 맞지 않으면 SignatureException이 발생한다.
  • 토큰 구조가 이상하면 MalformedJwtException이 발생한다.

이 흐름이 성공하면 서버는 해당 토큰을 신뢰할 수 있다고 판단할 수 있다.

// JwtValidator.java
package com.example.security10.app;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

public class JwtValidator {

    // JwtExam1과 같은 비밀 키를 사용해야 검증할 수 있다.
    private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret";

    // 검증에 사용할 SecretKey 객체를 만든다.
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

    public boolean validateToken(String token) {
        try {
            // 토큰 구조, 서명, 만료 시간을 검증한다.
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(getSigningKey())
                    .build()
                    .parseClaimsJws(token);

            // 예외가 발생하지 않으면 유효한 토큰이다.
            return true;
        } catch (ExpiredJwtException e) {
            // 토큰 만료 예외를 처리한다.
            System.out.println("토큰 만료됨: " + e.getMessage());
        } catch (SignatureException e) {
            // 서명 검증 실패 예외를 처리한다.
            System.out.println("토큰 서명 검증 실패: " + e.getMessage());
        } catch (MalformedJwtException e) {
            // 토큰 구조 이상 예외를 처리한다.
            System.out.println("토큰 구조 이상: " + e.getMessage());
        } catch (Exception e) {
            // 그 밖의 검증 실패를 처리한다.
            System.out.println("기타 토큰 검증 에러: " + e.getMessage());
        }

        // 검증 실패 시 false를 반환한다.
        return false;
    }
}

JwtValidator에서 가장 중요한 부분은 setSigningKey(getSigningKey())이다.
JWT를 만들 때 사용한 비밀 키와 검증할 때 사용하는 비밀 키가 같아야 한다.
키가 다르면 서버가 다시 계산한 Signature와 토큰 안의 Signature가 달라지기 때문이다.


parseClaimsJws(token)은 이름만 보면 단순히 Claims를 꺼내는 메서드처럼 보일 수 있다.
하지만 실제로는 토큰을 파싱하면서 구조, 서명, 만료 시간을 함께 확인한다.
토큰 구조가 정상이고, 서명이 맞고, 만료되지 않았으면 예외 없이 지나간다.
그러면 true가 반환된다.


이 코드에서는 parseClaimsJws(token)의 결과를 Jws<Claims> claims 변수에 담고 있다.
현재 예제에서는 claims 값을 따로 출력하거나 꺼내 쓰지는 않는다.
하지만 이 변수에는 검증을 통과한 토큰의 Header, Payload, Signature 정보가 들어 있다고 이해하면 된다.


반대로 토큰이 만료되었거나, 서명이 다르거나, 토큰 문자열 구조가 깨졌다면 예외가 발생한다.
이때 false가 반환되고, 서버는 해당 토큰을 신뢰하지 않는다.


parseClaimsJws()는 단순 디코딩이 아니라, 서버가 토큰을 신뢰할 수 있는지 확인하는 검증 흐름과 연결된다.


JwtExam2는 생성한 토큰을 검증한다

JwtExam2는 JwtExam1에서 생성한 토큰을 JwtValidator에 넣어 검증 결과를 확인하는 실행 예제이다.


이때 주의할 점은 토큰을 복사할 때 줄바꿈이나 공백이 들어가면 안 된다는 것이다.
JWT는 Header.Payload.Signature 형태의 한 줄 문자열이어야 한다.
중간에 공백이 들어가면 토큰 구조가 깨진 것으로 판단될 수 있다.

// JwtExam2.java
package com.example.security10.app;

public class JwtExam2 {
    public static void main(String[] args) {
        // JwtValidator 객체를 만든다.
        JwtValidator validator = new JwtValidator();

        // JwtExam1에서 만들어진 실제 JWT 문자열을 넣는다.
        String token = "JwtExam1에서 만들어진 토큰 내용";

        // validator 객체의 validateToken()으로 토큰을 검증한다.
        if (validator.validateToken(token)) {
            System.out.println("JWT 토큰이 유효합니다.");
        } else {
            System.out.println("JWT 토큰이 유효하지 않습니다.");
        }
    }
}

JwtExam2의 token 변수에는 JwtExam1에서 생성한 실제 토큰 전체를 넣는다.
이때 "JwtExam1에서 만들어진 토큰 내용"이라는 문구를 그대로 넣는 것이 아니다.
그 위치에 콘솔에서 복사한 실제 JWT 문자열을 공백 없이 붙여넣어야 한다.


또 "생성된 JWT 토큰:" 같은 출력 문구는 넣으면 안 된다.
토큰 문자열만 넣어야 한다.
토큰이 만료되기 전에 바로 검증해야 하므로 JwtExam1 실행 후 곧바로 복사해서 JwtExam2에 넣는 것이 좋다.


이 검증이 성공한다는 것은 서버가 해당 토큰을 신뢰할 수 있다는 뜻이다.
정확히는 토큰 구조가 정상이고, Signature가 맞으며, 만료 시간이 지나지 않았다는 뜻이다.

JwtValidator는 전달받은 JWT의 구조, 서명, 만료 시간을 확인한다.
토큰 구조가 정상이고, 서명이 맞고, 만료되지 않았다면 검증에 성공한다.
토큰이 만료되었거나 복사 과정에서 문자열이 잘못되었거나 비밀 키가 맞지 않으면 검증에 실패한다.
이 결과는 서버가 클라이언트가 보낸 토큰을 신뢰할 수 있는지 확인하는 과정과 연결된다.


JwtExam3에서 Access Token과 Refresh Token 생성하기

Access Token과 Refresh Token을 함께 만든다

JwtExam3은 Access Token과 Refresh Token을 함께 생성하는 예제이다.
두 토큰은 모두 JWT 형식이지만 목적이 다르다.


Access Token은 실제 요청에서 인증에 사용하는 토큰이다.
예를 들어 사용자가 게시글 목록을 요청하거나, 내 정보를 조회할 때 서버에 함께 보내는 토큰이다.


Refresh Token은 Access Token을 다시 발급받기 위해 사용하는 토큰이다.
Access Token은 자주 사용되기 때문에 유효 시간을 짧게 설정하는 경우가 많다.
대신 Refresh Token을 상대적으로 길게 유지해서 새 Access Token을 발급받을 수 있게 한다.


이 예제에서 두 토큰은 모두 JWT 형식으로 만들어진다.
그래서 둘 다 sub, iat, exp 같은 클레임을 가질 수 있다.
다만 Access Token과 Refresh Token은 목적이 다르기 때문에 만료 시간인 exp 값이 다르게 설정된다.


이 예제에서는 Access Token의 유효 시간을 30분으로 설정한다.
Refresh Token의 유효 시간은 7일로 설정한다.
따라서 두 토큰을 디코딩해 보면 exp 값이 서로 다르게 나온다.

// JwtExam3.java
package com.example.security10.app;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

public class JwtExam3 {

    // Access Token과 Refresh Token 서명에 사용할 비밀 키이다.
    private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret";

    // Access Token 유효 시간은 30분이다.
    private static final long ACCESS_TOKEN_VALIDITY = 1000 * 60 * 30;
    // Refresh Token 유효 시간은 7일이다.
    private static final long REFRESH_TOKEN_VALIDITY = 1000 * 60 * 60 * 24 * 7;

    // 문자열 비밀 키를 SecretKey 객체로 바꾼다.
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

    // Access Token을 생성한다.
    public String createAccessToken(String subject) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);

        return Jwts.builder()
                // 토큰 주체를 저장한다.
                .setSubject(subject)
                // 토큰 발급 시간을 저장한다.
                .setIssuedAt(now)
                // Access Token 만료 시간을 저장한다.
                .setExpiration(expiryDate)
                // 비밀 키와 HS256 알고리즘으로 서명한다.
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                // 최종 JWT 문자열을 만든다.
                .compact();
    }

    // Refresh Token을 생성한다.
    public String createRefreshToken(String subject) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);

        return Jwts.builder()
                // 토큰 주체를 저장한다.
                .setSubject(subject)
                // 토큰 발급 시간을 저장한다.
                .setIssuedAt(now)
                // Refresh Token 만료 시간을 저장한다.
                .setExpiration(expiryDate)
                // 비밀 키와 HS256 알고리즘으로 서명한다.
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                // 최종 JWT 문자열을 만든다.
                .compact();
    }

    // Access Token과 Refresh Token을 함께 발급한다.
    public TokenPair generateTokenPair(String subject) {
        String accessToken = createAccessToken(subject);
        String refreshToken = createRefreshToken(subject);
        return new TokenPair(accessToken, refreshToken);
    }

    // 두 토큰을 함께 담는 내부 DTO 클래스이다.
    public static class TokenPair {
        private final String accessToken;
        private final String refreshToken;

        // Access Token과 Refresh Token을 함께 저장한다.
        public TokenPair(String accessToken, String refreshToken) {
            this.accessToken = accessToken;
            this.refreshToken = refreshToken;
        }

        // Access Token을 반환한다.
        public String getAccessToken() {
            return accessToken;
        }

        // Refresh Token을 반환한다.
        public String getRefreshToken() {
            return refreshToken;
        }
    }

    public static void main(String[] args) {
        // 토큰 생성 객체를 만든다.
        JwtExam3 provider = new JwtExam3();

        // unico@example.com을 subject로 넣어 두 토큰을 함께 생성한다.
        TokenPair tokens = provider.generateTokenPair("unico@example.com");

        // Access Token을 출력한다.
        System.out.println("엑세스 토큰: " + tokens.getAccessToken());
        // Refresh Token을 출력한다.
        System.out.println("리프레쉬 토큰: " + tokens.getRefreshToken());
    }
}

ACCESS_TOKEN_VALIDITY는 Access Token의 유효 시간이다.
실제 코드에서는 1000 * 60 * 30으로 설정되어 있으므로 30분이다.


REFRESH_TOKEN_VALIDITY는 Refresh Token의 유효 시간이다.
실제 코드에서는 1000 * 60 * 60 * 24 * 7로 설정되어 있으므로 7일이다.


Claims는 JWT의 Payload 정보 묶음을 표현할 때 사용하는 타입이다.
다만 이 예제의 핵심은 토큰을 생성하는 흐름이므로, JwtExam3 안에서 Claims 값을 직접 꺼내 사용하지는 않는다.


createAccessToken()은 실제 요청 인증에 사용할 토큰을 만든다.
createRefreshToken()은 새 Access Token을 발급받는 데 사용할 토큰을 만든다.
두 메서드는 모두 setSubject(), setIssuedAt(), setExpiration(), signWith(), compact() 흐름으로 JWT를 만든다.


두 토큰의 가장 큰 차이는 setExpiration()에 들어가는 만료 시간이다.
Access Token은 30분 뒤 만료되고, Refresh Token은 7일 뒤 만료된다.
그래서 디코딩했을 때 sub와 iat는 비슷하게 보일 수 있지만, exp는 서로 다르게 나온다.


generateTokenPair()는 두 토큰을 함께 발급한다.
이렇게 묶어서 반환하면 로그인 성공 시 클라이언트에게 두 토큰을 함께 전달하는 구조를 만들 수 있다.


TokenPair는 별도 파일이 아니라 JwtExam3 안에 들어 있는 static 내부 클래스이다.
이 클래스는 Access Token과 Refresh Token을 하나의 결과처럼 묶어 다루기 위해 사용한다.

JwtExam3에서는 Access Token과 Refresh Token을 함께 생성한다.
Access Token은 실제 요청에서 인증에 사용하는 토큰이고, Refresh Token은 Access Token을 다시 발급받기 위해 사용하는 토큰이다.
두 토큰은 목적이 다르기 때문에 유효 시간도 다르게 설정할 수 있다.


Access Token 디코딩 결과 확인하기

Access Token을 JWT Debugger에 넣으면 Payload에서 sub, iat, exp를 확인할 수 있다.


sub는 토큰의 주체이다.
이 예제에서는 unico@example.com이 들어간다.
iat는 토큰 발급 시간이다.
exp는 토큰 만료 시간이다.


Access Token은 실제 요청에서 자주 사용되는 토큰이므로 보통 짧은 유효 시간을 가진다.
이 예제에서는 30분 뒤 만료되도록 설정되어 있다.

Access Token을 디코딩하면 Payload에서 sub, iat, exp를 확인할 수 있다.
sub에는 unico@example.com이 들어가고, exp는 Access Token의 만료 시간을 나타낸다.
이 토큰은 실제 인증이 필요한 요청에 사용하는 토큰이므로 상대적으로 짧은 유효 시간을 가진다.
JWT Debugger에 Invalid Signature가 보이더라도 이 화면은 토큰의 Payload 구조와 만료 시간 값을 확인하기 위한 결과물로 보면 된다.


Refresh Token 디코딩 결과 확인하기

Refresh Token도 JWT 형식이다.
따라서 JWT Debugger에서 디코딩하면 sub, iat, exp를 확인할 수 있다.


다만 Refresh Token은 Access Token보다 오래 유지된다.
이 예제에서는 7일 뒤 만료되도록 설정되어 있다.
그래서 Access Token과 비교하면 exp 값이 더 뒤의 시간으로 나온다.

Refresh Token을 디코딩하면 Payload에서 sub, iat, exp를 확인할 수 있다.
Refresh Token은 Access Token을 다시 발급받기 위한 토큰이므로 상대적으로 긴 유효 시간을 가진다.
그래서 평소 요청에는 Access Token을 사용하고, Access Token이 만료되었을 때 Refresh Token을 사용해 새 토큰을 발급받는 구조로 이어질 수 있다.
JWT Debugger에 Invalid Signature가 보이더라도 이 화면은 토큰의 Payload 구조와 만료 시간 값을 확인하기 위한 결과물로 보면 된다.


security10 JWT 생성과 검증 핵심 정리

security10에서는 먼저 JwtExam1로 하나의 JWT를 생성했다.
JWT는 Header, Payload, Signature가 점(.)으로 연결된 문자열이다.
setClaims()는 직접 만든 클레임을 Payload에 넣고, setSubject(), setIssuedAt(), setExpiration()은 각각 sub, iat, exp 값을 설정한다.
signWith()는 Signature를 만드는 흐름이다.


signWith(getSigningKey(), SignatureAlgorithm.HS256)에서 HS256은 JWT Header의 alg 값과 연결되는 서명 알고리즘이다.
getSigningKey()는 문자열로 된 SECRET_KEY를 서명과 검증에 사용할 키 객체로 바꾼다.
HS256 방식에서는 토큰을 생성할 때와 검증할 때 같은 Secret Key를 사용해야 한다.


JwtValidator와 JwtExam2에서는 생성한 JWT가 유효한지 검증했다.
검증할 때는 토큰 구조, 서명, 만료 시간을 확인한다.
토큰이 정상이고 만료되지 않았다면 서버는 해당 토큰을 신뢰할 수 있다.


JwtExam3에서는 Access Token과 Refresh Token을 함께 생성했다.
Access Token은 실제 요청에서 인증에 사용하는 토큰이고, Refresh Token은 Access Token을 다시 발급받기 위해 사용하는 토큰이다.
두 토큰은 모두 JWT 형식이지만 목적이 다르기 때문에 유효 시간도 다르게 설정할 수 있다.


security10의 핵심은 JWT가 코드에서 어떻게 생성되고, 어떤 기준으로 검증되며, Access Token과 Refresh Token을 왜 나누어 사용하는지 이해하는 것이다.




JWT 장점과 사용 이유

JWT는 서버가 로그인 상태를 매 요청마다 세션 저장소에서 찾지 않아도, 요청에 담긴 토큰을 검증해 사용자를 확인할 수 있게 해 준다.
이 구조는 REST API, SPA, 모바일 앱처럼 서버와 클라이언트가 분리된 환경에서 자주 사용된다.


하지만 JWT가 무조건 더 안전하거나 항상 좋은 방식이라는 뜻은 아니다.
JWT는 서버 확장성과 요청 처리 흐름에서는 장점이 있지만, 토큰이 탈취되었을 때의 위험과 이미 발급한 토큰을 즉시 무효화하기 어렵다는 문제도 함께 가진다.


앞에서 JWT는 Header, Payload, Signature로 구성된다고 정리했다.
이 구조 덕분에 서버는 Signature를 검증해 토큰이 조작되지 않았는지 확인할 수 있다.
하지만 Payload는 디코딩하면 볼 수 있는 영역이므로, 민감한 정보를 넣으면 안 된다.


JWT는 서버 확장성과 분리된 클라이언트 구조에 잘 맞지만, 토큰 저장 위치와 만료 시간까지 함께 설계해야 안전하게 사용할 수 있다.


JWT를 사용하는 이유

서버가 세션 저장소에 로그인 상태를 계속 의존하지 않아도 된다

Session 기반 인증에서는 서버가 로그인 상태를 세션 저장소에 보관한다.
브라우저는 Session ID를 보내고, 서버는 그 Session ID로 세션 저장소에서 사용자 정보를 찾는다.


반면 JWT 기반 인증에서는 일반적으로 클라이언트가 토큰을 가지고 요청한다.
서버는 요청에 담긴 JWT의 서명과 만료 시간을 검증하고, 검증이 성공하면 토큰 안의 사용자 정보를 기준으로 요청을 처리할 수 있다.
즉, 매 요청마다 세션 저장소에서 로그인 상태를 찾는 구조와 다르게 동작할 수 있다.


예를 들어 게시글 목록 요청이 들어왔다고 생각하면 된다.
Session 방식에서는 서버가 Session ID로 세션 저장소를 조회해서 사용자를 확인한다.
JWT 방식에서는 요청 헤더에 들어온 토큰을 검증하고, 토큰 안의 사용자 식별 정보를 읽어서 요청을 처리할 수 있다.


다만 이 말은 서버가 어떤 정보도 절대 저장하지 않는다는 뜻이 아니다.
실제 서비스에서는 Refresh Token, 로그아웃된 토큰 목록, 차단된 토큰 목록, 권한 변경 내역 등을 서버에서 관리할 수 있다.
이 단계에서는 JWT가 일반적인 세션 저장소 의존을 줄일 수 있는 구조라고 이해하면 된다.


JWT는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 찾는 부담을 줄이고, 토큰 검증 중심으로 사용자를 확인할 수 있게 해 준다.


JWT의 장점

Signature로 데이터 위변조를 확인할 수 있다

JWT는 Header, Payload, Signature로 구성된다.
여기서 Signature는 Header와 Payload가 중간에 바뀌었는지 확인하는 값이다.


예를 들어 Payload에 role: USER가 들어 있다고 생각하면 된다.
누군가 이 값을 role: ADMIN으로 바꾸면 Payload 내용은 바뀐다.
하지만 공격자가 서버의 Secret Key나 개인키를 모르면 바뀐 내용에 맞는 올바른 Signature를 만들 수 없다.


서버는 토큰을 받을 때 자신이 가진 검증 키나 비밀 키를 기준으로 서명이 유효한지 확인한다.
HS256 같은 HMAC 방식에서는 같은 Secret Key로 다시 계산해 비교하고, RS256 같은 공개키 기반 방식에서는 공개키로 서명이 유효한지 확인한다.
서명 검증에 실패하면 토큰이 조작되었거나 신뢰할 수 없는 토큰이라고 판단할 수 있다.


이 점 때문에 JWT는 클라이언트가 요청에 담아 보내는 토큰이면서도, 서버가 토큰 내용의 위변조 여부를 확인할 수 있다.
다만 Signature는 내용을 숨기는 기능이 아니다.
내용이 바뀌었는지 확인하는 기능이다.


Signature는 토큰 내용을 숨기는 기능이 아니라, 토큰 내용이 중간에 바뀌었는지 확인하는 기능이다.


별도의 세션 저장소 부담을 줄일 수 있다

Session 방식에서는 로그인한 사용자 수가 많아질수록 서버가 관리해야 하는 세션 정보도 많아진다.
서버가 여러 대라면 세션 정보를 여러 서버가 함께 볼 수 있도록 공유하거나, 특정 사용자의 요청이 같은 서버로 가도록 맞추는 작업도 필요할 수 있다.


JWT 방식에서는 요청마다 토큰이 함께 오고, 서버는 그 토큰을 검증해서 사용자를 판단할 수 있다.
따라서 일반적인 세션 저장소를 계속 조회하는 부담을 줄일 수 있다.


예를 들어 API 서버가 1대에서 3대로 늘어난다고 생각하면 된다.
Session 방식에서는 세션 정보를 여러 서버가 같이 볼 수 있도록 별도 설정이 필요할 수 있다.
JWT 방식에서는 각 서버가 같은 검증 키를 알고 있다면, 요청에 담긴 토큰을 각자 검증할 수 있다.


다만 JWT를 사용한다고 해서 모든 서버 저장소가 사라지는 것은 아니다.
Refresh Token을 서버에 저장하거나, 로그아웃된 토큰을 관리하는 구조를 만들 수도 있다.
그래서 정확히는 JWT가 세션 저장소 의존을 줄일 수 있다고 이해하는 것이 좋다.


필요한 정보를 토큰 자체에 담을 수 있다

JWT의 Payload에는 사용자 식별 정보나 권한 정보 같은 클레임을 담을 수 있다.
예를 들어 username, role, exp 같은 값이 들어갈 수 있다.


서버는 토큰을 검증한 뒤 Payload에서 필요한 값을 꺼내 사용할 수 있다.
그래서 단순한 사용자 식별이나 권한 확인에서는 매번 DB를 조회하지 않아도 되는 경우가 있다.


하지만 이 장점은 조심해서 이해해야 한다.
토큰 안의 정보는 발급 시점의 정보이다.
사용자 권한이 중간에 바뀌었는데 이미 발급된 토큰 안에는 예전 권한이 남아 있을 수 있다.


예를 들어 어떤 사용자가 처음에는 ADMIN 권한을 가지고 있었고, 그 상태로 JWT를 발급받았다고 하자.
그 뒤에 서버에서 이 사용자의 권한을 USER로 낮췄더라도, 이미 발급된 토큰 안에는 기존 ADMIN 정보가 남아 있을 수 있다.
그래서 중요한 권한 변경이 있는 서비스에서는 추가 검증이나 토큰 재발급 전략이 필요하다.


JWT의 Payload에 담긴 정보는 발급 시점의 정보이므로, 권한 변경처럼 중요한 정보는 추가 확인이 필요할 수 있다.


서버 확장에 유리하다

JWT는 Stateless 구조와 잘 맞는다.
Stateless는 서버가 이전 요청의 로그인 상태를 세션처럼 계속 기억하지 않고, 요청에 포함된 정보를 바탕으로 처리하는 구조이다.


이 구조는 서버를 여러 대로 늘릴 때 유리할 수 있다.
각 서버가 같은 방식으로 토큰을 검증할 수 있다면, 어느 서버가 요청을 받아도 사용자 인증을 처리할 수 있기 때문이다.


예를 들어 API 서버가 여러 대로 나뉘어 있어도, 클라이언트가 요청마다 JWT를 함께 보내면 각 서버는 토큰을 검증해서 사용자를 확인할 수 있다.
그래서 REST API, 모바일 앱, 마이크로서비스 구조에서 JWT가 자주 사용된다.


다만 Stateless라고 해서 서비스 전체가 아무 상태도 관리하지 않는다는 뜻은 아니다.
토큰 재발급, 로그아웃 처리, 토큰 차단 목록처럼 서버가 별도로 관리해야 하는 상태가 생길 수 있다.
따라서 JWT는 세션 의존을 줄이는 구조에 가깝다고 이해하면 된다.


다른 인증 시스템과 토큰 검증 흐름에 활용할 수 있다

JWT는 토큰 안에 사용자 식별 정보와 권한 정보를 담을 수 있다.
이 특징 때문에 여러 시스템이 같은 인증 정보를 기준으로 사용자를 확인하는 구조에서 활용될 수 있다.


예를 들어 회사 안에 인사 시스템, 결재 시스템, 관리자 시스템이 따로 있다고 생각하면 된다.
사용자가 한 번 로그인한 뒤 발급받은 토큰을 각 시스템에 전달하면, 각 시스템은 토큰을 검증해서 같은 사용자인지 확인할 수 있다.
다만 이 구조가 가능하려면 각 시스템이 토큰 검증 기준을 공유해야 한다.
예를 들어 같은 Secret Key를 사용하거나, 공개키 기반 방식에서는 같은 공개키로 서명을 검증할 수 있어야 한다.


OAuth, 소셜 로그인, SSO 같은 구조에서도 토큰 기반 인증 흐름이 사용될 수 있다.
다만 이런 구조에서 사용하는 토큰이 항상 JWT 형식이라는 뜻은 아니다.
구현 방식에 따라 JWT 형태의 토큰을 사용할 수도 있고, 다른 형식의 토큰을 사용할 수도 있다.


이 구간에서 중요한 점은 JWT가 여러 서비스가 같은 기준으로 인증 정보를 검증하는 구조에 활용될 수 있다는 점이다.
여러 시스템이 토큰을 공유할수록 토큰 검증 키와 만료 시간 관리가 더 중요해진다.
검증 기준이 맞지 않으면 어떤 서비스에서는 정상 토큰으로 보이고, 다른 서비스에서는 잘못된 토큰으로 처리될 수 있기 때문이다.


모바일 애플리케이션 환경에서도 잘 동작한다

모바일 앱은 서버가 화면을 직접 만들어 주는 방식보다 API 호출 중심으로 동작하는 경우가 많다.
앱은 로그인 후 토큰을 저장하고, 이후 요청마다 서버에 토큰을 함께 보낸다.


예를 들어 쇼핑 앱에서 사용자가 내 주문 목록을 조회한다고 생각하면 된다.
앱은 서버에 주문 목록 요청을 보내면서 JWT를 함께 보낸다.
서버는 토큰을 검증한 뒤 해당 사용자의 주문 목록을 응답할 수 있다.


이처럼 모바일 앱과 API 서버 구조에서는 요청마다 인증 정보를 함께 보내는 토큰 방식이 자연스럽다.
그래서 JWT는 모바일 애플리케이션 환경에서도 자주 사용된다.


JWT의 단점

Self-contained 구조는 양날의 검이다

JWT는 필요한 정보를 토큰 자체에 담을 수 있다.
이런 특징을 Self-contained 구조라고 볼 수 있다.
Self-contained는 필요한 내용을 자기 안에 가지고 있다는 뜻이다.


이 구조는 장점이 될 수 있다.
서버가 토큰을 검증한 뒤 Payload에서 사용자 정보나 권한 정보를 바로 읽을 수 있기 때문이다.


하지만 동시에 단점도 된다.
토큰 안에 들어간 정보는 발급 시점의 정보이다.
사용자 권한이 바뀌었거나 계정이 정지되었는데도, 이미 발급된 토큰에는 이전 정보가 남아 있을 수 있다.


예를 들어 사용자의 권한이 ADMIN에서 USER로 바뀌었다고 생각하면 된다.
그런데 기존에 발급된 토큰 안에 여전히 role: ADMIN이 들어 있고, 그 토큰이 아직 만료되지 않았다면 문제가 생길 수 있다.
그래서 중요한 권한 정보는 토큰만 믿지 않고 추가 확인이 필요할 수 있다.


Payload는 암호화된 공간이 아니다

JWT에서 가장 먼저 조심해야 할 부분은 Payload이다.
Payload는 Base64 URL-safe 방식으로 인코딩될 뿐, 암호화된 값이 아니다.


즉, 토큰을 가진 사람은 JWT Debugger 같은 도구로 Payload 내용을 확인할 수 있다.
그래서 비밀번호, 주민등록번호, 카드번호처럼 노출되면 안 되는 값은 절대 넣으면 안 된다.


Payload에는 사용자 식별자나 권한처럼 서버가 인증 판단에 사용할 수 있는 값을 넣을 수 있다.
하지만 노출되어도 치명적인 문제가 되는 민감 정보는 넣지 않는 것이 원칙이다.


JWT의 Payload는 디코딩해서 볼 수 있으므로 민감한 정보를 담으면 안 된다.


토큰 길이가 길어질 수 있다

JWT는 Header, Payload, Signature를 모두 포함한다.
Payload에 담는 정보가 많아질수록 토큰 문자열도 길어진다.


토큰은 요청마다 서버로 전달될 수 있다.
토큰이 너무 길어지면 매 요청마다 함께 보내야 하는 데이터 양도 늘어난다.


따라서 JWT에는 꼭 필요한 정보만 담는 것이 좋다.
예를 들어 사용자 식별값, 권한, 만료 시간 정도는 사용할 수 있지만, 사용자 상세 프로필 전체를 토큰에 담는 것은 적절하지 않다.


토큰은 인증 요청마다 반복해서 전달될 수 있기 때문에, Payload를 작게 유지하는 것이 좋다.


토큰이 탈취되면 만료 전까지 악용될 수 있다

JWT는 일반적으로 클라이언트가 가지고 있다가 요청마다 서버에 보낸다.
그래서 토큰이 탈취되면 공격자가 그 토큰을 이용해 인증된 사용자처럼 요청할 수 있다.


예를 들어 사용자의 Access Token이 노출되었다고 생각하면 된다.
공격자는 그 토큰을 Authorization: Bearer 토큰값 형태로 요청에 담아 보낼 수 있다.
서버가 토큰을 유효하다고 판단하면 정상 사용자 요청처럼 처리할 위험이 있다.


그래서 Access Token은 보통 짧은 유효 시간을 가진다.
만료 시간을 짧게 두면 토큰이 노출되더라도 악용 가능한 시간을 줄일 수 있다.


이 단점은 다음 구간의 Access Token과 Refresh Token 분리 전략으로 이어진다.
실제 인증 구조에서는 자주 쓰는 Access Token은 짧게 유지하고, 새 토큰 발급에 사용하는 Refresh Token은 더 조심해서 관리하는 방식이 사용된다.


이미 발급한 토큰을 즉시 무효화하기 어렵다

Session 방식에서는 서버가 세션 저장소에서 해당 세션을 삭제하면 로그인 상태를 끊을 수 있다.
서버가 로그인 상태를 직접 관리하기 때문에 무효화가 비교적 명확하다.


반면 JWT는 발급된 뒤 일반적으로 클라이언트가 가지고 다닌다.
서버가 토큰 자체를 저장하지 않는 구조라면, 이미 발급된 토큰을 즉시 무효화하기 어렵다.
토큰이 만료될 때까지 유효한 토큰으로 남을 수 있다.


이 문제를 줄이기 위해 실제 서비스에서는 여러 방법을 함께 사용할 수 있다.

  • Access Token 만료 시간을 짧게 설정한다.
  • Refresh Token은 서버에 저장하고 관리한다.
  • 로그아웃된 토큰이나 차단할 토큰을 별도 목록으로 관리한다.

즉, JWT는 편하지만 토큰 생명주기 관리가 중요하다.
발급만 하고 끝나는 것이 아니라, 만료 시간과 재발급 흐름까지 함께 설계해야 한다.


JWT는 발급 후 만료 전까지 유효하게 남을 수 있으므로, 짧은 만료 시간과 재발급 전략을 함께 설계해야 한다.


Session 기반 인증과 JWT 기반 인증 비교

두 방식은 로그인 상태를 관리하는 위치가 다르다

Session 기반 인증과 JWT 기반 인증은 모두 로그인한 사용자를 확인하기 위한 방식이다.
하지만 로그인 상태를 관리하는 위치와 서버가 사용자를 확인하는 방식이 다르다.


Session 방식은 서버가 로그인 상태를 세션 저장소에 저장한다.
클라이언트는 Session ID를 보내고, 서버는 세션 저장소에서 사용자 정보를 찾는다.


JWT 방식은 일반적으로 클라이언트가 토큰을 들고 요청한다.
서버는 세션 저장소에서 로그인 상태를 찾는 대신, 전달받은 토큰의 서명과 만료 시간을 검증한다.

Session 기반 인증은 서버 세션 저장소에 로그인 상태를 유지한다.
반면 JWT 기반 인증은 서버가 상태를 계속 기억하지 않는 Stateless 구조에 가깝다.
JWT는 서버 확장에 유리할 수 있지만, 토큰 자체를 신뢰하는 구조이므로 탈취된 토큰에 바로 대응하기 어렵다는 단점도 있다.


비교해서 정리하기

두 방식을 비교하면 다음과 같다.

  • Session 방식은 서버가 로그인 상태를 저장한다.
  • JWT 방식은 일반적으로 클라이언트가 인증 토큰을 저장한다.
  • Session 방식은 세션 저장소 조회가 필요하다.
  • JWT 방식은 토큰 서명과 만료 시간을 검증한다.
  • Session 방식은 서버에서 세션을 삭제해 무효화하기 쉽다.
  • JWT 방식은 이미 발급된 토큰을 즉시 무효화하기 어렵다.
  • Session 방식은 서버 중심 웹 서비스에 자연스럽다.
  • JWT 방식은 API 중심 서비스와 서버 확장 구조에 잘 맞는다.

둘 중 하나가 무조건 더 좋은 방식은 아니다.
화면 중심의 전통적인 웹 서비스에서는 Session 방식이 단순하고 자연스러울 수 있다.
반대로 React 같은 프론트엔드와 Spring Boot API 서버가 분리된 구조에서는 JWT 방식이 잘 맞을 수 있다.


JWT 장점과 사용 이유 핵심 정리

JWT는 Header, Payload, Signature를 가진 토큰이다.
서버는 Signature를 검증해서 토큰이 중간에 조작되었는지 확인할 수 있다.


JWT는 일반적인 세션 저장소에 로그인 상태를 계속 저장하지 않아도 인증 흐름을 만들 수 있다는 장점이 있다.
그래서 REST API, SPA, 모바일 앱, 마이크로서비스처럼 서버와 클라이언트가 분리된 구조에 잘 맞는다.


하지만 JWT에는 단점도 있다.
Payload는 암호화된 공간이 아니므로 민감한 정보를 넣으면 안 된다.
토큰이 탈취되면 만료 전까지 악용될 수 있고, 이미 발급한 토큰을 즉시 무효화하기 어렵다.


그래서 실제 인증 구조에서는 Access Token과 Refresh Token을 나누어 사용하는 경우가 많다.
Access Token은 실제 요청 인증에 사용하고 짧게 유지한다.
Refresh Token은 새 Access Token을 발급받는 용도로 사용하고 더 조심해서 관리한다.


JWT는 서버 확장성과 API 중심 구조에 유리하지만, 토큰 탈취 위험과 무효화 문제를 함께 고려해야 한다.




Access Token과 Refresh Token

JWT를 인증에 사용할 때 토큰 하나만 오래 유지하면 위험이 커진다.
토큰이 탈취되면 만료되기 전까지 공격자가 인증된 사용자처럼 요청할 수 있기 때문이다.
그래서 실제 인증 구조에서는 Access Token과 Refresh Token을 나누어 사용하는 경우가 많다.


두 토큰은 모두 인증 흐름에서 사용되지만 역할이 다르다.
Access Token은 실제 API 요청에 사용하는 토큰이고, Refresh Token은 Access Token이 만료되었을 때 새 Access Token을 발급받기 위해 사용하는 토큰이다.


앞에서 JWT는 서버 확장성과 API 중심 구조에 유리하지만, 토큰 탈취 위험과 무효화 문제를 함께 고려해야 한다고 정리했다.
Access Token과 Refresh Token을 나누는 이유도 이 문제와 연결된다.
자주 사용하는 토큰은 짧게 유지하고, 새 토큰을 발급받기 위한 토큰은 더 조심해서 관리하는 방식이다.


Access Token은 실제 요청 인증용이고, Refresh Token은 새 Access Token을 발급받기 위한 재발급용 토큰이다.


Access Token과 Refresh Token의 차이

두 토큰은 목적과 유효 시간이 다르다

Access Token은 사용자가 보호된 자원에 접근할 때 사용하는 토큰이다.
보호된 자원은 로그인한 사용자만 볼 수 있는 데이터나 기능을 의미한다.
예를 들어 내 정보 조회, 주문 목록 조회, 게시글 작성 같은 요청이 여기에 해당한다.


클라이언트는 서버에 요청을 보낼 때 Authorization 헤더에 Access Token을 담아 보낸다.
서버는 이 토큰의 서명과 만료 시간을 검증한다.
토큰이 유효하면 요청한 기능을 처리한다.


Refresh Token은 평소 API 요청에 계속 사용하는 토큰이 아니다.
Access Token이 만료되었을 때 새 Access Token을 발급받기 위해 사용하는 토큰이다.


두 토큰의 차이는 다음처럼 정리할 수 있다.

  • Access Token은 실제 API 요청 인증에 사용한다.
  • Refresh Token은 Access Token 재발급에 사용한다.
  • Access Token은 짧은 유효 기간을 가진다.
  • Refresh Token은 상대적으로 긴 유효 기간을 가진다.
  • Access Token은 요청마다 자주 전달된다.
  • Refresh Token은 Access Token이 만료되었을 때 사용된다.

예를 들어 출입증과 재발급 카드로 생각하면 된다.
Access Token은 문을 열 때마다 보여주는 출입증에 가깝다.
Refresh Token은 출입증이 만료되었을 때 새 출입증을 다시 받기 위한 재발급용 카드에 가깝다.

Access Token은 실제 리소스 접근 인증에 사용하는 토큰이다.
유효 기간은 짧게 설정하는 경우가 많고, 모든 API 요청에 자주 사용된다.
반면 Refresh Token은 Access Token을 다시 발급받기 위한 토큰이다.
상대적으로 긴 유효 기간을 가지며, 보통 HttpOnly Cookie, DB, Redis처럼 더 안전하게 관리할 수 있는 위치에 저장한다.
두 토큰은 모두 인증 흐름에 사용되지만, 목적과 저장 위치, 사용 빈도, 보안 위험, 서버 저장 여부가 다르다.


Access Token 인증 절차

Access Token은 요청마다 사용자를 증명하는 데 사용된다

Access Token 인증 흐름은 사용자가 로그인한 뒤 실제 API를 호출할 때의 흐름이다.
서버는 로그인 성공 시 Access Token을 발급한다.
클라이언트는 이 토큰을 저장해 두었다가 인증이 필요한 요청마다 함께 보낸다.


흐름은 다음과 같다.

  • 사용자가 아이디와 비밀번호로 로그인한다.
  • 서버는 계정 정보를 확인한다.
  • 서버는 사용자 식별 정보와 권한 정보를 Payload에 넣을 수 있다.
  • 서버는 만료 시간을 설정한다.
  • 서버는 Secret Key로 서명해 Access Token을 발급한다.
  • 클라이언트는 Access Token을 저장한다.
  • 클라이언트는 인증이 필요한 요청마다 Authorization 헤더에 토큰을 담아 보낸다.
  • 서버는 토큰의 Signature와 만료 시간을 검증한다.
  • 검증에 성공하면 서버는 요청을 처리한다.

예를 들어 사용자가 로그인한 뒤 /api/orders로 주문 목록을 요청한다고 생각하면 된다.
클라이언트는 요청 헤더에 Authorization: Bearer AccessToken값을 담아 보낸다.
서버는 이 토큰을 검증한 뒤, 토큰에서 사용자 정보를 확인하고 해당 사용자의 주문 목록을 응답한다.


Access Token은 실제 요청에 자주 사용되므로 탈취 위험을 줄이기 위해 유효 시간을 짧게 설정하는 경우가 많다.
유효 시간이 짧으면 토큰이 노출되더라도 악용 가능한 시간을 줄일 수 있다.

클라이언트가 인증 정보 없이 요청하면 서버는 401 Unauthorized를 응답할 수 있다.
이후 클라이언트는 Authorization 헤더에 인증 정보를 담아 다시 요청한다.
형식은 Authorization: <type> <credentials>이다.
JWT 인증에서는 보통 <type>에 Bearer를 사용하고, <credentials> 자리에 Access Token 값을 넣는다.
서버는 이 토큰을 검증한 뒤 요청을 허용하거나 거부한다.


Access Token이 만료되면 생기는 문제

짧은 유효 시간은 안전하지만 사용성을 떨어뜨릴 수 있다

Access Token을 짧게 유지하면 보안에는 도움이 된다.
하지만 너무 짧으면 사용자는 자주 다시 로그인해야 할 수 있다.


예를 들어 Access Token의 유효 시간이 1분이라고 생각하면 된다.
사용자는 로그인한 지 1분이 지나면 Access Token이 만료된다.
이때 매번 다시 아이디와 비밀번호를 입력해야 한다면 사용하기 불편하다.


그래서 Refresh Token이 필요하다.
Refresh Token은 사용자가 다시 로그인하지 않아도 새 Access Token을 발급받을 수 있게 도와준다.


이 구조를 사용하면 Access Token은 짧게 유지해서 위험을 줄이고, Refresh Token으로 사용성을 보완할 수 있다.
즉, 보안과 편의성을 함께 고려하는 구조이다.


Refresh Token 인증 절차

Refresh Token은 Access Token을 다시 발급받기 위해 사용된다

Refresh Token은 Access Token이 만료되었을 때 사용하는 토큰이다.
평소 모든 API 요청에 계속 보내는 토큰이 아니라, 새 Access Token을 발급받는 요청에서 사용된다.


흐름은 다음과 같다.

  • 사용자가 로그인한다.
  • 서버는 Access Token과 Refresh Token을 함께 발급한다.
  • 클라이언트는 평소 Access Token으로 API를 요청한다.
  • 서버는 Access Token을 검증하고 데이터를 응답한다.
  • Access Token이 만료되면 서버는 만료 응답을 보낸다.
  • 클라이언트는 Refresh Token으로 새 Access Token을 요청한다.
  • 서버는 Refresh Token을 검증한다.
  • 검증에 성공하면 서버는 새 Access Token을 발급한다.
  • Refresh Token도 만료되었거나 유효하지 않으면 다시 로그인해야 한다.

여기서 중요한 점은 Refresh Token도 안전하게 관리해야 한다는 것이다.
Refresh Token이 탈취되면 공격자가 새 Access Token을 계속 발급받을 수 있기 때문이다.
그래서 실제 서비스에서는 Refresh Token을 서버에 저장하거나, HttpOnly Cookie처럼 JavaScript에서 직접 읽기 어려운 저장 방식과 함께 사용하는 경우가 있다.

사용자가 로그인하면 서버는 Access Token과 Refresh Token을 함께 발급한다.
클라이언트는 평소 요청에는 Access Token을 사용한다.
서버는 요청마다 Access Token을 검증하고, 토큰이 유효하면 데이터를 응답한다.
Access Token이 만료되면 서버는 만료 응답을 보내고, 클라이언트는 Refresh Token을 이용해 새 Access Token을 요청한다.
서버는 Refresh Token을 확인한 뒤 새로운 Access Token을 다시 발급한다.


Refresh Token을 사용할 때 주의할 점

Refresh Token은 새 Access Token을 발급할 수 있기 때문에 더 조심해야 한다

Refresh Token은 Access Token을 다시 발급받기 위해 사용하는 토큰이다.
그래서 Access Token보다 상대적으로 오래 유지되는 경우가 많다.


하지만 오래 유지된다는 것은 그만큼 탈취되었을 때 위험도 커질 수 있다는 뜻이다.
공격자가 Refresh Token을 가져가면 새 Access Token을 발급받으려고 시도할 수 있다.
그래서 Refresh Token은 단순히 클라이언트에 저장해 두는 값으로만 보면 안 된다.


Refresh Token을 보호하기 위한 대표 방법은 다음과 같다.

  • HttpOnly Cookie에 저장해서 JavaScript에서 직접 읽기 어렵게 만든다.
  • 서버 DB에 사용자 계정별 Refresh Token을 저장하고 관리한다.
  • 로그아웃하거나 의심스러운 요청이 발생하면 서버에 저장된 Refresh Token을 폐기하거나 블랙리스트로 처리한다.
  • Refresh Token 재발급 요청이 들어왔을 때 IP나 디바이스 정보를 함께 확인해 접속 환경이 크게 달라졌는지 검사한다.

HttpOnly Cookie는 브라우저가 쿠키를 저장하더라도 JavaScript 코드로 직접 읽지 못하게 막는 설정이다.
이렇게 하면 스크립트 공격으로 토큰이 바로 노출되는 위험을 줄일 수 있다.


서버 DB에 저장하는 방식은 서버가 Refresh Token을 직접 관리할 수 있게 만든다.
이 방식에서는 특정 사용자의 Refresh Token을 무효화하거나 블랙리스트로 처리할 수 있다.
즉, 이미 발급된 토큰을 서버 기준으로 더 세밀하게 통제할 수 있다.


IP와 디바이스 기반 검증은 재발급 요청이 평소와 다른 환경에서 들어왔는지 확인하는 보조 방법이다.
예를 들어 평소와 전혀 다른 지역이나 기기에서 갑자기 Refresh Token 요청이 들어오면 위험 신호로 볼 수 있다.


Refresh Token은 새 Access Token을 발급받을 수 있는 중요한 값이므로, 저장 위치와 서버 관리 전략을 함께 설계해야 한다.


Access Token과 Refresh Token 핵심 정리

Access Token과 Refresh Token은 모두 인증 흐름에서 사용되는 토큰이다.
하지만 역할은 다르다.


Access Token은 실제 API 요청에 사용된다.
클라이언트는 인증이 필요한 요청마다 Access Token을 보내고, 서버는 토큰을 검증해서 사용자를 확인한다.
Access Token은 자주 사용되므로 보통 짧은 유효 시간을 가진다.


Refresh Token은 새 Access Token을 발급받기 위해 사용된다.
Access Token이 만료되었을 때 Refresh Token을 서버에 보내면, 서버는 이를 검증한 뒤 새 Access Token을 발급할 수 있다.


두 토큰을 나누는 이유는 보안과 편의성을 함께 잡기 위해서이다.
Access Token은 짧게 유지해서 탈취 위험을 줄이고, Refresh Token은 새 토큰을 발급받는 용도로 사용해 사용자가 계속 다시 로그인하지 않아도 되게 만든다.


다만 Refresh Token은 더 오래 유지되는 경우가 많기 때문에 더 안전하게 관리해야 한다.
Refresh Token이 탈취되면 공격자가 새 Access Token을 발급받을 수 있기 때문이다.


Access Token은 요청 인증용 토큰이고, Refresh Token은 새 Access Token을 발급받기 위한 재발급용 토큰이다.




security10: SpringMVC에서 JWT 발급 기능 테스트

security10은 SpringMVC에서 JWT를 발급하고, 발급된 토큰을 다시 요청 Header에 담아 검증하는 실습이다.
앞에서는 JWT를 순수 Java 코드로 직접 생성하고 검증했다.
이번에는 웹 요청 흐름 안에서 JWT가 어떻게 오고 가는지 확인한다.


이 실습에서는 같은 로그인 요청이라도 토큰을 내려주는 방식이 다르게 구성된다.
/auth1과 /auth2는 모두 토큰을 발급하는 요청이다.
차이는 토큰을 내려주는 위치이다.
/auth1은 생성된 JWT를 응답 Body에 담아 내려주고, /auth2는 생성된 JWT를 응답 Header의 Authorization에 담아 내려준다.
반면 /auth3는 토큰을 새로 발급하는 요청이 아니다.
이미 발급받은 토큰을 요청 Header의 Authorization에 담아 보내고, 서버가 그 안에서 실제 JWT를 꺼내 로그인 상태를 확인하는 요청이다.


security10의 핵심은 JWT를 어디에 담아 발급하는지, 그리고 클라이언트가 발급받은 토큰을 다시 어떤 형식으로 서버에 보내는지 이해하는 것이다.


security10에서 확인할 전체 흐름

JWT 발급과 검증을 요청 단위로 나누어 본다

security10에서는 JWT를 단순히 콘솔에 출력하지 않는다.
클라이언트가 서버에 로그인 요청을 보내고, 서버가 JWT를 발급한 뒤, 클라이언트가 다시 그 토큰을 요청에 담아 보내는 흐름을 확인한다.


전체 흐름은 다음과 같다.

  • AuthenticationDTO는 로그인 요청 데이터를 담는다.
  • JwtUtil은 JWT 생성과 파싱을 담당한다.
  • AuthController1은 JWT를 응답 Body로 내려준다.
  • AuthController2는 JWT를 응답 Header의 Authorization에 내려준다.
  • AuthController3은 요청 Header의 Authorization 값을 읽고 로그인 상태를 확인한다.
  • auth23.html은 브라우저에서 /auth2와 /auth3 흐름을 확인한다.

이 흐름을 이해하면 JWT가 단순히 생성되는 문자열이 아니라, 실제 로그인 요청과 인증 확인 요청 사이에서 어떻게 전달되는지 볼 수 있다.


여기서 중요한 점은 /auth1, /auth2, /auth3의 역할을 구분하는 것이다.
/auth1과 /auth2는 토큰을 발급하는 요청이다.
/auth1은 응답 본문에서 토큰을 바로 확인하는 방식이고, /auth2는 응답 헤더에서 토큰을 확인하는 방식이다.
반면 /auth3는 이미 받은 토큰을 다시 서버에 보내서 로그인 상태를 확인하는 요청이다.


JwtUtil은 JWT 생성과 파싱을 담당한다

JwtUtil은 토큰 관련 기능을 한곳에 모아 둔다

JwtUtil은 JWT를 생성하고 해석하는 기능을 담당한다.
Controller 안에서 직접 토큰 생성 코드를 모두 작성하면 코드가 복잡해진다.
그래서 토큰 생성과 파싱 기능을 JwtUtil 같은 별도 클래스로 분리한다.


JwtUtil의 역할은 크게 두 가지이다.

  • 로그인한 사용자를 기준으로 JWT를 생성한다.
  • 요청으로 받은 JWT를 파싱해서 Claims를 꺼낸다.

Claims는 JWT의 Payload에 들어 있는 정보 묶음이다.
예를 들어 sub, iat, exp 같은 값이 Claims 안에 들어 있다.


예를 들어 username이 "unico"라고 생각하면 된다.
서버는 "unico"를 sub에 넣어 JWT를 생성할 수 있다.
나중에 클라이언트가 이 토큰을 다시 보내면 서버는 토큰을 파싱해서 sub에 들어 있는 "unico"를 꺼낼 수 있다.

// JwtUtil.java
package com.example.security10.service;

import java.security.Key;
import java.util.Date;

import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

@Service
public class JwtUtil {
    // JWT 서명에 사용할 비밀 키이다.
    private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret";

    // 문자열 비밀 키를 서명용 Key 객체로 바꾼다.
    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
    }

    // username을 기준으로 JWT를 생성한다.
    public String generateToken(String username) {
        long now = System.currentTimeMillis();
        long exp = now + 1000 * 60 * 1; // 1분 유효

        System.out.println("username : " + username);
        return Jwts.builder()
                // sub에 사용자 이름을 넣는다.
                .setSubject(username)
                // iat에 발급 시간을 넣는다.
                .setIssuedAt(new Date(now))
                // exp에 만료 시간을 넣는다.
                .setExpiration(new Date(exp))
                // Secret Key와 HS256으로 서명한다.
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                // 최종 JWT 문자열을 만든다.
                .compact();
    }

    // JWT 토큰을 파싱하고 Claims를 추출한다.
    public Claims parseToken(String token) {
        Jws<Claims> jwsClaims = Jwts.parser()
                // 토큰 검증에 사용할 Key를 지정한다.
                .setSigningKey(getSigningKey())
                // parser를 완성한다.
                .build()
                // JWT 구조와 서명을 검증하며 파싱한다.
                .parseClaimsJws(token);

        System.out.println("jwsClaims : " + jwsClaims);
        // Payload에 해당하는 Claims를 반환한다.
        return jwsClaims.getBody();
    }
}

generateToken()은 JWT를 만드는 메서드이다.
setSubject(username)으로 토큰의 주체를 넣고, setIssuedAt()으로 발급 시간을 넣는다.
setExpiration()은 토큰 만료 시간을 넣는 부분이다.
마지막으로 signWith()에서 Secret Key로 서명하고, compact()로 최종 JWT 문자열을 만든다.


parseToken()은 전달받은 JWT를 해석하는 메서드이다.
서버는 토큰을 그대로 믿지 않는다.
setSigningKey()에 검증용 키를 넣고 parseClaimsJws()를 실행해 토큰 구조, 서명, 만료 시간을 확인한다.
검증이 통과되면 Payload의 Claims를 꺼내 반환한다.


만약 토큰이 만료되었거나, 서명이 맞지 않거나, 토큰 구조가 잘못되었다면 parseClaimsJws() 과정에서 예외가 발생할 수 있다.
그래서 뒤에서 /auth3는 try-catch를 사용해 만료된 토큰과 그 밖의 오류를 따로 처리한다.
이 흐름을 알면 JwtUtil의 파싱 기능과 AuthController3의 예외 처리 코드가 자연스럽게 연결된다.


AuthenticationDTO는 로그인 요청 데이터를 담는다

요청 Body의 JSON 값을 객체로 받는다

AuthenticationDTO는 로그인 요청 데이터를 담는 객체이다.
클라이언트가 로그인 요청을 보낼 때 username과 password를 JSON 형태로 보낸다.
서버는 이 요청 Body를 AuthenticationDTO로 받아 사용할 수 있다.


요청 데이터는 아래처럼 들어온다.

// 요청 Body
{
  "username": "unico",
  "password": "0000"
}

username은 사용자를 구분하기 위한 값이다.
password는 비밀번호 값이다.
이번 실습의 핵심은 실제 비밀번호 검증 로직이 아니라, 로그인 요청을 받았다고 가정하고 JWT를 발급하는 흐름을 확인하는 것이다.

// AuthenticationDTO.java
package com.example.security10.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class AuthenticationDTO {
    // 로그인 요청에서 전달되는 사용자 이름이다.
    private String username;
    // 로그인 요청에서 전달되는 비밀번호이다.
    private String password;
}

AuthenticationDTO가 있으면 Controller는 요청 Body의 값을 하나씩 직접 꺼내지 않아도 된다.
SpringMVC가 JSON의 username, password 값을 객체의 필드에 매핑해 준다.
이렇게 요청 데이터를 객체로 받으면 로그인 처리 코드가 더 읽기 쉬워진다.


이 흐름은 컨트롤러의 @RequestBody AuthenticationDTO authRequest와 직접 연결된다.
@RequestBody는 요청 Body에 들어온 JSON 데이터를 Java 객체로 바꿔서 매개변수에 넣어 준다.
그래서 요청 Body의 username 값은 authRequest.getUsername()으로 읽을 수 있고, password 값은 authRequest.getPassword()로 읽을 수 있다.


@Getter와 @Setter는 Lombok이 getter, setter 메서드를 자동으로 만들어 주는 어노테이션이다.
그래서 코드에 직접 getUsername(), setUsername()을 작성하지 않아도 컨트롤러에서 authDto.getUsername()처럼 값을 읽을 수 있다.


AuthController1에서 JWT를 응답 Body로 내려주기

auth1은 발급한 JWT를 Body에 담아 반환한다

AuthController1은 로그인 요청을 받아 JWT를 생성한 뒤, 생성된 토큰을 응답 Body로 내려주는 방식이다.
응답 Body는 서버가 클라이언트에게 보내는 응답 내용 영역이다.


/auth1 요청 흐름은 다음과 같다.

  • 클라이언트가 /auth1로 POST 요청을 보낸다.
  • 요청 Body에는 username, password가 들어 있다.
  • 서버는 AuthenticationDTO로 요청 데이터를 받는다.
  • 서버는 username을 기준으로 JWT를 생성한다.
  • 서버는 생성된 토큰을 JwtResponse 객체에 담아 응답 Body로 반환한다.

이 방식은 토큰이 응답 본문에 바로 보이기 때문에 테스트하기 쉽다.
Talend API Tester에서도 응답 Body를 보면 token 값을 바로 확인할 수 있다.

// AuthController1.java
package com.example.security10.controller;

import com.example.security10.dto.AuthenticationDTO;
import com.example.security10.service.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController1 {

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/auth1")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationDTO authRequest) {
        // 실제 환경에서는 user 검증 로직이 필요하다.
        // 여기서는 username을 기준으로 JWT를 생성한다.
        String jwt = jwtUtil.generateToken(authRequest.getUsername());

        // 생성된 JWT를 응답 Body에 담아 반환한다.
        return ResponseEntity.ok(new JwtResponse(jwt));
    }

    // JWT 응답 DTO이다.
    static class JwtResponse {
        private String token;

        // 생성된 JWT를 token 필드에 저장한다.
        public JwtResponse(String token) {
            this.token = token;
        }

        // token 값을 반환한다.
        public String getToken() {
            return token;
        }

        // token 값을 저장한다.
        public void setToken(String token) {
            this.token = token;
        }
    }
}

@RestController는 반환 값을 화면 이름으로 보지 않고 응답 데이터로 내려주는 컨트롤러이다.
그래서 ResponseEntity.ok(new JwtResponse(jwt))로 반환하면 JwtResponse 객체가 JSON 형태로 응답 Body에 들어간다.


JwtResponse는 응답 Body에 담을 토큰 값을 표현하는 객체이다.
응답 결과에서는 token이라는 이름으로 JWT가 내려온다.

/auth1 요청은 로그인 요청 데이터를 JSON 형태로 전달받는다.
서버는 username 값을 기준으로 JWT를 생성하고, 생성된 토큰을 응답 Body의 token 필드에 담아 반환한다.
응답 상태 코드는 200이고, 응답 Body에는 token 필드가 들어 있다.
이 방식에서는 JWT가 응답 본문에 들어오므로, 테스트 도구에서 Body 영역만 봐도 토큰을 바로 확인할 수 있다.


AuthController2에서 JWT를 Authorization Header로 내려주기

auth2는 발급한 JWT를 응답 Header에 담아 반환한다

AuthController2도 로그인 요청을 받아 JWT를 생성한다.
하지만 /auth1과 다르게 토큰을 응답 Body에 넣지 않는다.
대신 응답 Header의 Authorization에 담아 내려준다.


Header는 요청이나 응답에 대한 부가 정보를 담는 영역이다.
Authorization은 인증 정보를 전달할 때 자주 사용하는 헤더 이름이다.
JWT를 사용할 때는 보통 Bearer 타입과 함께 사용한다.


/auth2 요청 흐름은 다음과 같다.

  • 클라이언트가 /auth2로 POST 요청을 보낸다.
  • 요청 Body에는 username, password가 들어 있다.
  • 서버는 username을 기준으로 JWT를 생성한다.
  • 서버는 응답 Header의 Authorization에 Bearer 토큰값을 담는다.
  • 응답 Body에는 별도 내용을 담지 않는다.

이 방식에서는 응답 Body가 비어 있어도 실패가 아니다.
토큰이 응답 본문이 아니라 응답 Header에 들어 있기 때문이다.
그래서 Talend API Tester에서는 응답 Headers 영역을 반드시 확인해야 한다.

// AuthController2.java
package com.example.security10.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.example.security10.dto.AuthenticationDTO;
import com.example.security10.service.JwtUtil;

@RestController
public class AuthController2 {

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/auth2")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationDTO authDto) {
        // 실제 환경이라면 사용자 인증 로직이 필요하다.
        String jwt = jwtUtil.generateToken(authDto.getUsername());
        System.out.println("authDto : " + authDto);

        // JWT 토큰을 Authorization 헤더에 넣는다.
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + jwt);
        System.out.println(jwt);

        // 응답 Body 없이 Header만 포함해서 반환한다.
        return ResponseEntity.ok().headers(headers).build();
    }
}

HttpHeaders는 응답 Header 값을 구성할 때 사용하는 객체이다.
여기서는 headers.add("Authorization", "Bearer " + jwt)로 응답 헤더에 인증 정보를 추가한다.


ResponseEntity.ok().headers(headers).build()는 상태 코드 200과 응답 Header를 포함한 응답을 만든다.
build()를 사용했기 때문에 응답 Body에는 별도 데이터가 없다.
그래서 Talend API Tester에서는 Body 영역에 표시할 내용이 없게 보일 수 있지만, 이것은 HTTP 상태 코드가 204 No Content라는 뜻이 아니다.
이 코드는 ResponseEntity.ok()를 사용하므로 응답 상태 코드는 200이고, 토큰은 응답 Headers의 Authorization에서 확인해야 한다.

/auth2 요청은 로그인 요청 데이터를 받은 뒤 JWT를 생성한다.
생성된 토큰은 응답 Body가 아니라 응답 Header의 Authorization에 담긴다.
그래서 응답 본문에는 표시할 내용이 없게 보이지만, 응답 상태 코드는 200이다.
응답 헤더를 확인하면 Authorization: Bearer 토큰값 형태로 토큰이 내려온 것을 확인할 수 있다.
이미지에는 Authorization 헤더와 함께 Set-Cookie의 JSESSIONID도 보인다.
이 실습에서 핵심 인증 값은 Authorization에 들어 있는 Bearer 토큰이다.
뒤에서 RemoveJsessionIdFilter를 사용해 JSESSIONID가 함께 보이는 혼동을 줄이는 흐름을 확인한다.


AuthController3에서 Bearer Token을 읽고 로그인 상태 확인하기

auth3은 요청 Header의 Authorization 값을 읽는다

AuthController3은 토큰을 새로 발급하는 컨트롤러가 아니다.
이미 발급받은 JWT를 요청 Header에 담아 보냈을 때, 서버가 그 토큰을 읽고 로그인 상태를 확인하는 컨트롤러이다.


/auth3 요청 흐름은 다음과 같다.

  • 클라이언트가 /auth3로 GET 요청을 보낸다.
  • 요청 Header에 Authorization: Bearer 토큰값을 담는다.
  • 서버는 Authorization 헤더가 있는지 확인한다.
  • 값이 Bearer로 시작하는지 확인한다.
  • Bearer 뒤의 실제 JWT만 잘라낸다.
  • JwtUtil.parseToken()으로 토큰을 파싱한다.
  • 토큰의 Claims에서 sub 값을 읽어 로그인 사용자를 확인한다.

여기서 Bearer를 제거하는 이유는 실제 JWT 문자열만 파싱해야 하기 때문이다.
Authorization 헤더 전체 값은 Bearer 토큰값 형태이다.
하지만 JwtUtil.parseToken()에 넣어야 하는 값은 Bearer가 붙은 전체 문자열이 아니라, 뒤쪽의 실제 JWT 문자열이다.

// AuthController3.java
package com.example.security10.controller;

import java.nio.charset.StandardCharsets;

import com.example.security10.service.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController3 {
    @Autowired
    private JwtUtil jwtUtil;

    @GetMapping("/auth3")
    public ResponseEntity<String> extractToken(@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
        // Authorization 헤더가 없거나 Bearer 형식이 아니면 실패 응답을 반환한다.
        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Authorization 헤더가 없거나 형식이 올바르지 않습니다.");
        }

        // Bearer 접두어를 제거하고 실제 JWT만 추출한다.
        String token = authorizationHeader.substring(7);
        String subject = "";
        String content;

        try {
            // JWT를 파싱해서 Claims를 꺼낸다.
            Claims claims = jwtUtil.parseToken(token);
            // sub 값을 읽어 로그인 사용자를 확인한다.
            subject = claims.getSubject();
            content = "로그인 확인됩니다.." + subject + "회원님!!";
        } catch (ExpiredJwtException e) {
            // 만료된 토큰이면 재로그인 안내 메시지를 만든다.
            content = "전송된 JWT 의 유효시간이 지났습니다. 재로그인 하세요~~~";
        } catch (Exception e) {
            // 그 밖의 오류를 처리한다.
            e.printStackTrace();
            content = "오류가 발생했습니다";
        }

        // 한글 응답이 깨지지 않도록 text/plain UTF-8로 설정한다.
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(new MediaType("text", "plain", StandardCharsets.UTF_8));

        // 로그인 확인 메시지를 응답한다.
        return ResponseEntity.ok()
                .headers(headers)
                .body(content);
    }
}

@RequestHeader는 요청 Header 값을 컨트롤러 메서드 매개변수로 받게 해 준다.
여기서는 요청 Header의 Authorization 값을 authorizationHeader 변수로 받는다.


authorizationHeader.startsWith("Bearer ")는 헤더 값이 올바른 형식인지 확인한다.
Bearer로 시작하지 않으면 서버가 어떤 방식의 인증 정보인지 판단하기 어렵다.


authorizationHeader.substring(7)은 Bearer 부분을 잘라내는 코드이다.
Bearer 여섯 글자와 뒤의 공백 한 칸까지 포함하면 총 7글자이다.
그래서 7번째 위치부터 잘라 실제 JWT만 꺼낸다.


jwtUtil.parseToken(token)은 토큰을 파싱하고 검증한 뒤 Claims를 반환한다.
그 다음 claims.getSubject()로 sub 값을 꺼낸다.
이 값이 로그인 사용자를 확인하는 기준이 된다.


/auth3는 GET 요청이다.
따라서 요청 Body에 값을 넣어 보내는 방식이 아니라, 요청 Header의 Authorization에 Bearer 토큰값을 넣어 보내야 한다.
서버는 Bearer 접두어를 제거한 뒤 실제 JWT만 JwtUtil.parseToken()에 전달한다.

/auth3 요청은 요청 Header의 Authorization 값을 읽는다.
값이 Bearer로 시작하면 서버는 그 뒤의 실제 JWT를 꺼낸다.
이후 토큰을 파싱해서 Payload의 sub 값을 읽고, 로그인된 사용자를 확인한다.
이 결과에서는 토큰에서 unico 값을 꺼내 로그인 상태 확인 메시지를 응답한다.


Authorization 헤더가 없으면 실패한다

/auth3는 요청 Header에 Authorization 값이 있어야 정상적으로 동작한다.
이 값이 없으면 서버는 어떤 사용자의 요청인지 확인할 수 없다.


토큰이 없다는 것은 서버 입장에서 로그인 사용자를 증명할 정보가 없다는 뜻이다.
그래서 서버는 400 응답과 함께 Authorization 헤더가 없거나 형식이 올바르지 않다는 메시지를 반환한다.


이 실패는 AuthController3의 첫 번째 if문에서 발생한다.
authorizationHeader == null이거나 Bearer 로 시작하지 않으면 HttpStatus.BAD_REQUEST가 반환된다.
그래서 응답 상태 코드는 400이고, 응답 Body에는 형식 오류 메시지가 들어간다.

/auth3는 요청 Header에 Authorization: Bearer 토큰값이 있어야 정상 동작한다.
이 값이 없으면 서버는 어떤 사용자의 요청인지 확인할 수 없다.
그래서 Authorization 헤더가 없거나 형식이 올바르지 않다는 메시지와 함께 실패 응답을 반환한다.


Talend API Tester로 JWT 발급과 검증 결과 확인하기

auth1은 Body에서 token을 확인한다

Talend API Tester에서 /auth1을 테스트할 때는 POST 방식으로 요청한다.
요청 Body에는 로그인 정보를 JSON으로 넣는다.


요청 주소는 아래와 같다.

// 요청 주소
POST http://localhost:9000/auth1

요청 Body는 아래처럼 작성한다.

// 요청 Body
{
  "username": "unico",
  "password": "0000"
}

Content-Type은 application/json으로 설정한다.
요청을 보내면 응답 Body에 token 값이 내려온다.


auth2는 Header에서 Authorization을 확인한다

/auth2도 POST 방식으로 요청한다.
요청 Body는 /auth1과 같다.


요청 주소는 아래와 같다.

// 요청 주소
POST http://localhost:9000/auth2

요청 Body는 아래처럼 작성한다.

// 요청 Body
{
  "username": "unico",
  "password": "0000"
}

요청을 보내면 응답 Body에는 표시할 내용이 없을 수 있다.
하지만 실패가 아니다.
/auth2는 토큰을 응답 본문이 아니라 응답 Headers의 Authorization에 담아 내려주는 방식이기 때문이다.
여기에 Bearer 토큰값 형태의 JWT가 들어 있다.


auth3은 auth2에서 받은 Authorization 값을 그대로 사용한다

/auth3은 GET 방식으로 요청한다.
이 요청에는 Body를 넣지 않는다.
대신 /auth2 응답 Header에서 받은 Authorization 값을 요청 Header에 넣어야 한다.


요청 주소는 아래와 같다.

// 요청 주소
GET http://localhost:9000/auth3

요청 Header에는 아래 값을 넣는다.

// 요청 Header
Authorization: Bearer 토큰값

여기서 중요한 점은 /auth2에서 받은 값을 그대로 복사하는 것이다.
Bearer를 빼고 토큰만 넣으면 안 된다.
AuthController3이 Authorization 값이 Bearer 로 시작하는지 확인하기 때문이다.


/auth3 요청이 성공하면 응답 Body에 로그인 확인 메시지가 나온다.
요청 Header에 Authorization 값을 넣지 않으면 실패 응답이 나온다.


auth23.html에서 JWT 로그인 기능 테스트하기

브라우저에서도 JWT 로그인 흐름을 확인할 수 있다

auth23.html은 브라우저에서 JWT 로그인 흐름을 확인하는 화면이다.
Talend API Tester로는 요청과 응답을 직접 확인했다.
auth23.html에서는 사용자가 버튼을 누르는 흐름으로 /auth2와 /auth3 동작을 확인한다.


접속 주소는 아래와 같다.

// 접속 주소
http://localhost:9000/auth23.html

화면에서 계정과 암호를 입력하고 로그인하면 브라우저는 /auth2로 로그인 요청을 보낸다.
서버는 응답 Header의 Authorization에 Bearer Token을 담아 내려준다.
브라우저 코드는 응답 Header의 Authorization 값에서 Bearer 접두어를 제거한 뒤 실제 JWT만 localStorage에 jwtToken 이름으로 저장한다.


이 실습에서는 흐름 확인을 위해 localStorage에 토큰을 저장한다.
localStorage는 브라우저 저장소이며 JavaScript로 값을 읽을 수 있다.
그래서 실제 서비스에서는 토큰 저장 위치와 보안 정책을 별도로 신중하게 정해야 한다.


그 다음 JWT 읽기 기능으로 저장된 토큰을 확인할 수 있다.
로그인 상태 확인을 실행하면 저장된 토큰을 다시 꺼내 Authorization: Bearer 토큰값 형태로 /auth3 요청에 담아 보낸다.
서버는 토큰을 검증하고, 토큰 안의 사용자 정보를 읽어 로그인 상태를 응답한다.

브라우저 화면에서 로그인 요청, JWT 저장, JWT 읽기, 로그인 상태 확인까지 이어지는 전체 흐름이다.
사용자가 로그인하면 서버는 /auth2에서 토큰을 발급하고, 브라우저는 이 토큰을 저장한다.
이후 로그인 상태 확인 요청에서는 저장된 토큰을 /auth3 요청 헤더에 담아 보내고, 서버는 토큰을 검증해 로그인 상태를 응답한다.


RemoveJsessionIdFilter로 JSESSIONID 제거하기

JWT 방식에서는 세션 쿠키가 필요하지 않을 수 있다

JWT 인증 흐름에서는 클라이언트가 JWT를 가지고 요청한다.
서버는 요청에 담긴 토큰을 검증해서 사용자를 확인한다.
이 구조에서는 전통적인 세션 기반 로그인처럼 JSESSIONID를 중심으로 인증 상태를 유지하지 않을 수 있다.


그런데 SpringMVC 환경에서는 상황에 따라 JSESSIONID 쿠키가 응답에 포함될 수 있다.
JSESSIONID는 서버 세션을 식별하기 위한 쿠키이다.
토큰 기반 인증 흐름을 확인하는 실습에서는 이 쿠키가 함께 보이면 인증 방식이 헷갈릴 수 있다.


JWT 실습에서는 로그인 상태를 Authorization 헤더의 토큰으로 확인해야 한다.
그런데 응답에 JSESSIONID가 함께 보이면 브라우저가 세션 쿠키로 로그인 상태를 유지하는 것처럼 오해할 수 있다.
그래서 RemoveJsessionIdFilter는 실습 결과에서 JSESSIONID 쿠키를 제거해, 인증 흐름이 JWT 중심이라는 점을 더 분명히 보여 주기 위한 코드이다.


RemoveJsessionIdFilter는 응답에서 JSESSIONID 관련 값을 제거하기 위한 필터이다.
필터는 요청이나 응답이 컨트롤러에 도달하기 전후에 중간에서 처리할 수 있는 구성 요소이다.

// RemoveJsessionIdFilter.java
package com.example.security10.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class RemoveJsessionIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        System.out.println("RemoveJsessionIdFilter 실행");

        // 다음 필터 또는 컨트롤러로 요청을 넘긴다.
        chain.doFilter(request, response);

        // 응답 객체를 HttpServletResponse로 변환한다.
        HttpServletResponse res = (HttpServletResponse) response;
        System.out.println("committed = " + res.isCommitted());

        // JSESSIONID 쿠키를 제거하도록 Set-Cookie 헤더를 설정한다.
        res.setHeader("Set-Cookie",
                "JSESSIONID=; Path=/; Max-Age=0; HttpOnly");
    }
}

@Component가 붙어 있기 때문에 이 필터는 Spring이 관리하는 객체로 등록된다.
그래서 요청과 응답 흐름 사이에서 실행될 수 있다.


chain.doFilter(request, response)는 다음 필터나 컨트롤러로 요청을 넘기는 코드이다.
이 코드가 실행된 뒤 응답이 만들어지고, 그 다음 Set-Cookie 헤더를 다시 설정한다.


committed는 응답이 이미 클라이언트로 확정되어 전송되기 시작했는지 확인하는 값이다.
응답이 이미 커밋된 상태라면 이후에 Header를 바꾸기 어렵다.
그래서 res.isCommitted() 출력은 필터가 응답을 수정할 수 있는 시점인지 확인하는 데 도움이 된다.


JSESSIONID=; Path=/; Max-Age=0; HttpOnly는 브라우저에게 JSESSIONID 쿠키를 제거하라고 알려 주는 값이다.
Max-Age=0은 쿠키를 즉시 만료시키겠다는 의미이다.


이 필터의 목적은 JWT 실습 결과를 볼 때 세션 쿠키 때문에 인증 방식이 헷갈리지 않게 하는 것이다.
JWT 방식의 핵심은 요청 Header의 Authorization에 담긴 토큰으로 사용자를 확인하는 것이다.


security10 JWT 발급 기능 테스트 핵심 정리

security10에서는 SpringMVC 요청 흐름 안에서 JWT를 발급하고 검증하는 구조를 확인했다.
/auth1은 JWT를 응답 Body로 내려준다.
/auth2는 JWT를 응답 Header의 Authorization에 담아 내려준다.


/auth3는 토큰을 새로 발급하는 요청이 아니다.
이미 발급받은 토큰을 요청 Header에 담아 보내고, 서버가 그 토큰을 파싱해서 로그인 상태를 확인하는 요청이다.
이때 요청 Header 값은 Authorization: Bearer 토큰값 형태여야 한다.


Talend API Tester에서는 /auth1, /auth2, /auth3 요청과 응답을 직접 확인할 수 있다.
auth23.html에서는 같은 흐름을 브라우저 화면에서 버튼 동작으로 확인할 수 있다.


security10의 핵심은 JWT를 어디에 담아 내려주는지, 그리고 클라이언트가 그 토큰을 다시 어떤 형식으로 서버에 보내는지 이해하는 것이다.




인증 기능 구현에 자주 사용하는 필터 클래스

Spring Security에서 인증 기능을 직접 구현할 때는 요청이 Controller에 도착하기 전에 먼저 가로채서 확인하는 구조를 사용한다.
이때 요청을 중간에서 검사하는 역할을 하는 것이 Filter이다.


Spring Security는 여러 필터가 순서대로 실행되는 구조로 동작한다.
로그인 요청을 처리하는 필터도 있고, 요청 Header에서 인증 정보를 꺼내 검증하는 필터도 있다.
JWT 인증을 직접 구현할 때도 이런 필터 구조 위에서 로그인 처리와 토큰 검증 흐름을 만든다.


인증 기능을 직접 구현하려면 Spring Security에서 자주 사용하는 필터 클래스의 역할 차이를 먼저 알아야 한다.


필터 클래스가 필요한 이유

요청은 Controller에 바로 도착하지 않는다

일반적인 웹 요청을 생각하면 사용자가 요청을 보내고, 그 요청이 바로 Controller 메서드로 들어가는 것처럼 보일 수 있다.
하지만 Spring Security가 적용되어 있으면 요청은 먼저 보안 필터들을 지나간다.


예를 들어 사용자가 /admin 주소로 요청을 보냈다고 생각하면 된다.
서버는 바로 AdminController를 실행하지 않는다.
먼저 이 요청이 로그인한 사용자의 요청인지, 필요한 권한이 있는지 확인한다.
이 확인 과정이 보안 필터에서 이루어진다.


JWT 인증도 마찬가지이다.
사용자가 요청 Header에 Authorization: Bearer 토큰값을 담아 보내면, 서버는 Controller로 보내기 전에 토큰을 먼저 확인해야 한다.
토큰이 없거나 잘못되었으면 요청을 막거나 인증되지 않은 요청으로 처리한다.
토큰이 유효하면 현재 사용자를 인증된 사용자로 등록하고 다음 단계로 넘긴다.


이런 작업을 하기 위해 직접 만든 JWTFilter 같은 필터가 필요하다.
그리고 그 필터를 만들 때 어떤 부모 필터 클래스를 상속할지 알아야 한다.
부모 필터 클래스를 잘못 고르면 로그인 요청을 처리해야 하는 필터인지, 이미 발급된 토큰을 검증해야 하는 필터인지 역할이 흐려질 수 있다.


UsernamePasswordAuthenticationFilter

사용자명과 비밀번호 로그인 요청을 처리하는 표준 필터이다

UsernamePasswordAuthenticationFilter는 사용자명과 비밀번호를 사용해서 인증을 시도하는 표준 Spring Security 필터이다.
주로 웹 폼 기반 로그인 처리를 다룰 때 사용된다.


여기서 웹 폼 기반 로그인은 사용자가 로그인 화면에서 계정과 비밀번호를 입력하고, 로그인 버튼을 누르는 방식을 말한다.
사용자가 입력한 값은 로그인 요청으로 서버에 전달된다.
UsernamePasswordAuthenticationFilter는 이 요청에서 사용자명과 비밀번호를 꺼내 인증을 시도한다.


이 필터는 AbstractAuthenticationProcessingFilter의 하위 클래스이다.
AbstractAuthenticationProcessingFilter는 인증 요청을 처리하는 필터의 공통 흐름을 가지고 있는 부모 클래스라고 이해하면 된다.
그 하위 클래스인 UsernamePasswordAuthenticationFilter는 그중에서도 사용자명과 비밀번호 기반 로그인에 특화되어 있다.


예를 들어 사용자가 아래처럼 로그인 요청을 보낸다고 생각하면 된다.

// 로그인 요청 예시
POST /login
username=unico
password=0000

UsernamePasswordAuthenticationFilter는 이 요청에서 username과 password를 꺼낸다.
그리고 그 값을 이용해 인증 객체를 만들고, 실제 인증 처리를 진행할 수 있도록 넘긴다.


JWT 기반 필터 로그인 예제에서는 이 필터를 그대로 쓰기보다, 같은 위치에 직접 만든 로그인 필터를 끼워 넣을 수 있다.
즉, 기존의 폼 로그인 필터가 하던 자리에 LoginFilter를 넣어서 사용자명과 비밀번호를 받고, 인증 성공 시 JWT를 발급하는 구조로 확장할 수 있다.


여기서 중요한 점은 UsernamePasswordAuthenticationFilter가 이미 발급된 JWT를 검증하는 필터가 아니라는 것이다.
이 필터의 중심 역할은 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 것이다.
로그인 성공 후 발급된 JWT를 매 요청마다 검증하는 역할은 보통 별도의 JWTFilter가 담당한다.


UsernamePasswordAuthenticationFilter는 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 필터이다.


GenericFilterBean

Spring 빈으로 관리되는 단순 필터를 만들 때 사용한다

GenericFilterBean은 단순한 필터 동작을 정의할 때 사용할 수 있는 필터 클래스이다.
일반적인 Servlet Filter 기능을 가지면서도 Spring의 빈 라이프사이클과 연결될 수 있다.


여기서 빈 라이프사이클은 Spring이 객체를 만들고, 초기화하고, 필요할 때 사용하고, 종료 시 정리하는 전체 과정을 의미한다.
GenericFilterBean을 사용하면 필터도 Spring이 관리하는 객체처럼 사용할 수 있다.


GenericFilterBean은 요청이 들어올 때마다 실행될 수 있다.
하지만 요청당 한 번만 실행되도록 보장해 주는 전용 기능은 기본적으로 제공하지 않는다.
그래서 필터 체인 흐름이나 요청 처리 방식에 따라 같은 요청에서 여러 번 실행될 가능성을 직접 고려해야 한다.


예를 들어 단순히 모든 요청에 대해 로그를 남기는 필터를 만든다고 생각하면 된다.
요청 주소, 요청 방식, 요청 시간을 출력하는 정도라면 GenericFilterBean으로도 구현할 수 있다.
하지만 JWT 인증처럼 같은 요청 흐름에서 인증 처리가 중복 실행되면 안 되는 경우에는 더 적합한 필터가 따로 있다.


GenericFilterBean은 필터 기능의 기본 형태를 만들 때는 유용하다.
하지만 JWT 인증처럼 같은 요청 처리 흐름에서 중복 실행을 막는 것이 중요한 경우에는 보통 OncePerRequestFilter를 더 많이 사용한다.


GenericFilterBean은 Spring과 통합된 필터를 만들 수 있지만, 요청당 한 번만 실행되도록 보장하는 전용 기능은 제공하지 않는다.


OncePerRequestFilter

같은 요청 처리 흐름에서 중복 실행을 막는 필터이다

OncePerRequestFilter는 GenericFilterBean의 하위 클래스이다.
이 필터의 핵심은 이름 그대로 같은 요청 처리 흐름에서 필터가 중복 실행되지 않도록 도와준다는 점이다.
정확히는 하나의 요청 디스패치 기준으로 한 번 실행되도록 보장하려는 필터 기본 클래스라고 이해하면 된다.


웹 요청은 내부적으로 여러 흐름을 거칠 수 있다.
예를 들어 요청이 다른 리소스로 전달되거나, 에러 처리 흐름을 타거나, 비동기 처리와 연결될 수 있다.
이런 상황에서 같은 필터가 중복 실행되면 인증 처리가 여러 번 반복될 수 있다.


JWT 인증에서는 요청 Header에서 토큰을 꺼내고, 토큰이 유효한지 검사하고, 유효하면 인증 정보를 SecurityContext에 저장한다.
이 작업은 한 요청에서 여러 번 반복될 필요가 없다.
오히려 중복 실행되면 흐름이 복잡해지고 예상하지 못한 문제가 생길 수 있다.


그래서 JWT 인증 필터를 만들 때는 OncePerRequestFilter를 자주 사용한다.
같은 요청 처리 흐름에서 인증 검사가 중복 실행되는 것을 막아 주기 때문에, 요청 Header에서 JWT를 꺼내 검증하는 작업에 잘 맞는다.


예를 들어 클라이언트가 아래처럼 요청을 보낸다고 생각하면 된다.

// JWT 인증 요청 예시
GET /api/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

OncePerRequestFilter를 상속한 JWTFilter는 이 요청에서 Authorization 헤더를 읽는다.
값이 없거나 Bearer로 시작하지 않으면 인증 정보를 등록하지 않고 다음 필터로 넘길 수 있다.
또는 구현 방식에 따라 보호된 요청에서는 바로 실패 처리할 수도 있다.
값이 올바르면 Bearer 뒤의 토큰만 꺼내고, 토큰 만료 여부와 서명을 검증한다.
검증에 성공하면 인증 객체를 만들어 SecurityContext에 저장한다.


여기서 SecurityContext는 현재 요청에서 인증된 사용자 정보를 보관하는 공간이다.
실제 코드에서는 보통 SecurityContextHolder가 관리하는 SecurityContext에 인증 객체를 저장한다.
JWT가 유효하다고 확인되면, 서버는 이 요청을 인증된 사용자의 요청으로 처리할 수 있다.


OncePerRequestFilter는 JWT처럼 같은 요청 처리 흐름에서 인증 검사가 중복 실행되면 안 되는 필터에 적합하다.


BasicAuthenticationFilter

HTTP Basic 인증을 처리하는 필터이다

BasicAuthenticationFilter는 HTTP Basic 인증을 처리하는 필터이다.
OncePerRequestFilter의 하위 클래스이다.


HTTP Basic 인증은 클라이언트가 사용자명과 비밀번호를 Base64 방식으로 인코딩해서 요청 Header의 Authorization에 담아 보내는 방식이다.
여기서 Base64는 데이터를 다른 문자 형태로 바꾸는 인코딩 방식이다.
암호화가 아니기 때문에 디코딩하면 원래 값을 확인할 수 있다.


요청 형태는 아래처럼 볼 수 있다.

// HTTP Basic 인증 요청 예시
Authorization: Basic dW5pY286MDAwMA==

여기서 Basic은 인증 타입이다.
뒤의 값은 사용자명과 비밀번호를 username:password 형식으로 합친 뒤 Base64로 인코딩한 값이다.
예를 들어 unico:0000 같은 문자열을 인코딩하면 Basic 뒤에 들어가는 값이 된다.


BasicAuthenticationFilter는 이런 Authorization 헤더를 읽고 HTTP Basic 인증 처리를 수행한다.
즉, JWT의 Bearer Token을 처리하는 필터가 아니라, Basic 인증을 처리하는 필터이다.


앞에서 Authorization 헤더의 인증 타입을 정리할 때 Basic과 Bearer가 서로 다른 인증 타입이라고 했다.
BasicAuthenticationFilter는 그중 Basic 타입 인증과 관련된다.
반면 JWT 인증에서는 보통 Authorization: Bearer 토큰값 형식을 사용하고, 직접 만든 JWTFilter가 이 값을 읽어 처리한다.


BasicAuthenticationFilter는 Bearer Token이 아니라 HTTP Basic 인증 정보를 처리하는 필터이다.


네 가지 필터 클래스 비교

이름이 비슷해도 역할이 다르다

네 가지 필터 클래스는 모두 보안 흐름과 관련되지만 역할이 다르다.
처음에는 이름이 비슷해서 헷갈릴 수 있다.
그래서 “무엇을 처리하는 필터인가”를 기준으로 나누어 보면 쉽다.

필터 클래스상속 관계핵심 역할JWT 인증과의 연결
UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter의 하위 클래스사용자명과 비밀번호 로그인 요청 처리직접 만든 LoginFilter를 이 위치에 넣어 로그인 성공 시 JWT를 발급할 수 있다.
GenericFilterBeanFilter 기반 Spring 통합 필터단순 필터 동작 정의기본 필터를 만들 수 있지만 요청당 한 번만 실행되도록 보장하는 전용 기능은 없다.
OncePerRequestFilterGenericFilterBean의 하위 클래스요청 처리 흐름에서 중복 실행 방지JWTFilter처럼 요청 Header의 토큰을 한 번 검증하는 필터에 적합하다.
BasicAuthenticationFilterOncePerRequestFilter의 하위 클래스HTTP Basic 인증 처리Bearer Token이 아니라 Basic 인증을 처리한다.

이 표에서 가장 중요한 것은 UsernamePasswordAuthenticationFilter와 OncePerRequestFilter의 차이이다.
UsernamePasswordAuthenticationFilter는 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 데 초점이 있다.
OncePerRequestFilter는 이미 발급된 토큰이 요청에 담겨 왔을 때, 같은 요청 처리 흐름에서 토큰 검증이 중복 실행되지 않도록 하는 데 적합하다.


즉, JWT 로그인 구조에서는 두 필터 역할이 나뉜다.
로그인할 때는 사용자명과 비밀번호를 받아 인증하고 토큰을 발급하는 필터가 필요하다.
로그인 이후 요청에서는 Authorization 헤더의 JWT를 읽어 인증 상태를 복원하는 필터가 필요하다.


인증 기능 구현에 자주 사용하는 필터 클래스 핵심 정리

Spring Security는 여러 필터가 순서대로 실행되는 구조로 동작한다.
인증 기능을 직접 구현하려면 요청이 Controller까지 가기 전에 어떤 필터가 어떤 역할을 하는지 알아야 한다.


UsernamePasswordAuthenticationFilter는 사용자명과 비밀번호 기반 로그인 요청을 처리하는 표준 필터이다.
GenericFilterBean은 Spring과 통합된 단순 필터를 만들 때 사용할 수 있다.
OncePerRequestFilter는 같은 요청 처리 흐름에서 중복 실행을 막는 데 도움을 주며, JWTFilter처럼 요청 Header에서 토큰을 꺼내 검증하는 필터에 적합하다.
BasicAuthenticationFilter는 HTTP Basic 인증을 처리하는 필터이다.


JWT 기반 인증에서는 로그인 요청을 처리하는 필터와, 발급된 토큰을 검증하는 필터의 역할을 나누어 이해해야 한다.




security11: JWT 토큰 기반 필터 로그인 예제 테스트

security11은 Spring Security 필터 구조 안에서 JWT 기반 로그인을 처리하는 예제이다.
앞에서는 security10에서 컨트롤러가 직접 /auth1, /auth2, /auth3 요청을 처리하면서 JWT를 발급하고 검증했다.
이번에는 인증 흐름을 Controller가 아니라 Spring Security 필터가 담당한다.


필터 로그인은 로그인 요청이 Controller까지 가지 않고 Spring Security Filter Chain 안에서 처리되는 방식이다.
사용자가 /login으로 username과 password를 보내면 LoginFilter가 이 요청을 가로채 인증을 시도한다.
인증에 성공하면 LoginFilter가 JWT를 발급하고, 응답 Header의 Authorization에 Bearer Token을 담아 내려준다.


그 다음 클라이언트는 발급받은 JWT를 요청 Header의 Authorization에 담아 보호된 요청을 보낸다.
서버는 JWTFilter에서 토큰을 확인하고, 토큰이 유효하면 인증 정보를 SecurityContextHolder에 저장한다.
이후 SecurityConfig의 권한 규칙에 따라 보호된 요청 접근 여부가 결정된다.


security11의 핵심은 LoginFilter가 로그인 성공 시 JWT를 발급하고, JWTFilter가 이후 요청에서 JWT를 검증해 인증 상태를 복원한다는 점이다.


security11에서 확인할 전체 흐름

Controller 중심 흐름에서 Filter 중심 흐름으로 바뀐다

security10에서는 /auth2 같은 컨트롤러 메서드가 로그인 요청을 받고 JWT를 발급했다.
반면 security11에서는 로그인 요청을 Spring Security 필터가 처리한다.


필터는 요청이 Controller에 도착하기 전에 먼저 실행된다.
따라서 로그인 요청이나 인증 확인 요청을 더 앞단에서 처리할 수 있다.
이 구조를 사용하면 인증 흐름을 애플리케이션 전체 요청 흐름에 자연스럽게 끼워 넣을 수 있다.


security11의 전체 흐름은 다음과 같다.

  • 애플리케이션을 실행하면 user_entity 테이블이 생성된다.
  • /join 요청으로 사용자를 가입시킨다.
  • 가입한 사용자의 비밀번호는 암호화되어 저장된다.
  • /login 요청으로 로그인한다.
  • 로그인 성공 시 응답 Header의 Authorization에 Bearer Token이 내려온다.
  • 발급된 JWT를 디코딩하면 username, role, iat, exp를 확인할 수 있다.
  • /admin 요청에 Bearer Token을 담아 보내면 서버가 토큰을 검증하고 인증 상태를 복원한다.
  • 복원된 인증 정보에 ADMIN 권한이 있으면 Admin Controller 응답을 받을 수 있다.

여기서 중요한 점은 /login 요청이 단순 컨트롤러 요청처럼 처리되는 것이 아니라는 점이다.
Spring Security 필터가 로그인 요청을 가로채고, 인증 성공 후 JWT를 발급하는 구조이다.


SecurityConfig에서 필터 흐름 설정하기

security11의 핵심 설정은 SecurityFilterChain에 있다

security11에서 가장 먼저 봐야 할 코드는 SecurityConfig이다.
여기에서 어떤 요청은 허용하고, 어떤 요청은 권한을 검사할지 정한다.
또 직접 만든 LoginFilter와 JWTFilter를 어느 위치에 넣을지도 정한다.


SecurityConfig에서 확인해야 할 핵심은 다음과 같다.

  • csrf를 비활성화한다.
  • 기본 formLogin 방식을 비활성화한다.
  • 기본 httpBasic 방식을 비활성화한다.
  • /login, /, /join 요청은 누구나 접근 가능하게 한다.
  • /admin 요청은 ADMIN 권한이 있어야 접근 가능하게 한다.
  • JWTFilter를 LoginFilter 앞에 등록한다.
  • LoginFilter를 UsernamePasswordAuthenticationFilter 위치에 등록한다.
  • 세션을 사용하지 않는 STATELESS 방식으로 설정한다.

이 설정이 있어야 security11이 세션 기반 로그인 흐름이 아니라, JWT 기반 필터 로그인 흐름으로 동작한다.

// SecurityConfig.java
package com.example.security11.config;

import com.example.security11.jwt.JWTFilter;
import com.example.security11.jwt.JWTUtil;
import com.example.security11.jwt.LoginFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {
        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtUtil = jwtUtil;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        // Spring Security의 인증 매니저를 꺼낸다.
        return configuration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        // 비밀번호 암호화에 사용할 BCryptPasswordEncoder를 등록한다.
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // csrf 보호 기능을 비활성화한다.
        http.csrf((auth) -> auth.disable());

        // 기본 form 로그인 방식을 사용하지 않는다.
        http.formLogin((auth) -> auth.disable());

        // 기본 http basic 인증 방식을 사용하지 않는다.
        http.httpBasic((auth) -> auth.disable());

        // 요청별 접근 권한을 설정한다.
        http.authorizeHttpRequests((auth) -> auth
                .requestMatchers("/login", "/", "/join").permitAll()
                .requestMatchers("/admin").hasAuthority("ADMIN")
                .anyRequest().authenticated());

        // JWTFilter를 LoginFilter 앞에 둔다.
        http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

        // LoginFilter를 UsernamePasswordAuthenticationFilter 위치에 둔다.
        http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil),
                UsernamePasswordAuthenticationFilter.class);

        // 세션을 만들지 않는 JWT 방식으로 설정한다.
        http.sessionManagement((session) -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // 설정한 보안 필터 체인을 반환한다.
        return http.build();
    }
}

formLogin()과 httpBasic()을 비활성화하는 이유는 기본 로그인 방식과 기본 HTTP Basic 인증 방식을 사용하지 않기 위해서이다.
이번 예제에서는 직접 만든 LoginFilter가 로그인 요청을 처리하고, 직접 만든 JWTFilter가 요청의 토큰을 검증한다.


requestMatchers("/login", "/", "/join").permitAll()은 로그인, 메인, 회원가입 요청을 누구나 접근할 수 있게 한다.
아직 로그인하지 않은 사용자도 회원가입과 로그인은 할 수 있어야 하기 때문이다.


requestMatchers("/admin").hasAuthority("ADMIN")은 /admin 요청에 ADMIN 권한이 필요하다는 뜻이다.
따라서 로그인한 사용자의 토큰에서 ADMIN 권한을 꺼내 인증 객체에 등록해야 /admin 요청에 접근할 수 있다.


addFilterAt()은 직접 만든 LoginFilter를 기존 UsernamePasswordAuthenticationFilter 위치에 넣는다.
이 위치는 사용자명과 비밀번호 기반 로그인 요청을 처리하는 자리이다.
그래서 /login 요청이 들어오면 LoginFilter가 사용자명과 비밀번호를 꺼내 인증을 시도할 수 있다.


addFilterBefore()는 JWTFilter를 LoginFilter보다 앞에 둔다.
JWTFilter는 이미 발급받은 토큰이 요청에 담겨 있는지 확인한다.
즉, 로그인 이후의 요청에서 Authorization 헤더를 읽고 인증 상태를 복원하는 역할을 한다.


SessionCreationPolicy.STATELESS는 서버가 인증 상태를 세션에 저장하지 않겠다는 설정이다.
이 설정을 사용하면 서버는 매 요청마다 클라이언트가 보낸 JWT를 기준으로 인증 상태를 판단한다.
그래서 JWT 기반 인증에서는 세션 저장이 아니라 토큰 검증 흐름이 중심이 된다.


LoginFilter는 로그인 요청에서 JWT를 발급하고, JWTFilter는 발급된 JWT를 이후 요청에서 검증한다.


JWTUtil에서 토큰 생성과 값 추출 처리하기

JWTUtil은 username과 role을 토큰에 넣고 다시 꺼낸다

JWTUtil은 JWT를 생성하고, 생성된 토큰에서 필요한 값을 꺼내는 클래스이다.
security11에서는 토큰 안에 username과 role을 넣는다.


username은 로그인한 사용자명이다.
role은 사용자의 권한 정보이다.
예를 들어 dooly 사용자가 ADMIN 권한을 가지고 있다면, 토큰의 Payload에는 username: dooly, role: ADMIN 같은 값이 들어간다.

// JWTUtil.java
package com.example.security11.jwt;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JWTUtil {
    private SecretKey secretKey;

    public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
        // 설정 파일의 secret 값을 HS256 서명에 사용할 키로 바꾼다.
        this.secretKey = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8),
                Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {
        // 토큰에서 username 값을 꺼낸다.
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token)
                .getPayload()
                .get("username", String.class);
    }

    public String getRole(String token) {
        // 토큰에서 role 값을 꺼낸다.
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token)
                .getPayload()
                .get("role", String.class);
    }

    public Boolean isExpired(String token) {
        // 토큰 만료 시간이 현재 시간보다 이전인지 확인한다.
        return Jwts.parser().verifyWith(secretKey).build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration()
                .before(new Date());
    }

    public String createJwt(String username, String role, Long expiredMs) {
        // username, role, 발급 시간, 만료 시간을 담아 JWT를 만든다.
        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }
}

JWTUtil 생성자에서는 설정 파일의 spring.jwt.secret 값을 받아 SecretKey 객체로 만든다.
JWT는 단순 문자열만으로 검증되는 것이 아니라, 서버가 가진 비밀 키를 기준으로 서명하고 검증된다.
그래서 토큰을 만들 때 사용한 키와 토큰을 읽을 때 사용하는 키가 같아야 한다.


createJwt()는 로그인 성공 시 호출된다.
이 메서드는 username, role, 발급 시간, 만료 시간을 토큰에 담는다.
마지막에 signWith()로 서명하고 compact()로 최종 JWT 문자열을 만든다.


getUsername()과 getRole()은 토큰에서 각각 사용자명과 권한 정보를 꺼낸다.
이때 단순히 문자열을 잘라서 읽는 것이 아니라, verifyWith(secretKey)로 서명을 검증하면서 토큰을 파싱한다.
서명이 맞지 않거나 토큰 형식이 잘못되면 정상적으로 값을 꺼낼 수 없다.


isExpired()는 토큰이 만료되었는지 확인한다.
JWT는 무한히 사용할 수 있는 값이 아니기 때문에, 서버는 요청이 들어올 때 토큰 만료 시간을 검사해야 한다.
토큰의 만료 시간이 현재 시간보다 이전이면 더 이상 유효한 토큰으로 볼 수 없다.

JWT는 Header, Payload, Signature로 구성된다.
Header에는 알고리즘 정보가 들어가고, Payload에는 사용자 정보와 권한 같은 데이터가 들어간다.
Signature는 Header와 Payload를 기준으로 서버의 Secret Key를 사용해 만든 값이다.
서버는 요청으로 받은 토큰을 다시 같은 키로 검증해서 토큰이 조작되지 않았는지 확인한다.


LoginFilter에서 로그인 요청 처리하기

LoginFilter는 username과 password로 인증을 시도한다

LoginFilter는 UsernamePasswordAuthenticationFilter를 상속한다.
이 필터는 /login 요청에서 사용자명과 비밀번호를 꺼내 인증을 시도한다.


attemptAuthentication()은 인증 시도를 담당한다.
요청에서 username과 password를 꺼내고, 이 값을 UsernamePasswordAuthenticationToken으로 만든다.
그 다음 AuthenticationManager에게 실제 인증을 맡긴다.


인증에 성공하면 successfulAuthentication()이 실행된다.
여기서는 인증된 사용자 정보에서 username과 role을 꺼낸다.
그리고 JWTUtil.createJwt()로 JWT를 생성한 뒤 응답 Header의 Authorization에 담아 내려준다.

// LoginFilter.java
package com.example.security11.jwt;

import com.example.security11.dto.CustomUserDetails;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.util.Collection;
import java.util.Iterator;

public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;

    public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        // 요청에서 username을 꺼낸다.
        String username = obtainUsername(request);
        // 요청에서 password를 꺼낸다.
        String password = obtainPassword(request);

        // 인증에 사용할 토큰 객체를 만든다.
        UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(username, password, null);

        // AuthenticationManager에게 실제 인증을 맡긴다.
        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain, Authentication authentication) {
        // 인증된 사용자 정보를 꺼낸다.
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        // 사용자명을 꺼낸다.
        String username = customUserDetails.getUsername();

        // 권한 목록에서 첫 번째 권한을 꺼낸다.
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        // 권한 문자열을 꺼낸다.
        String role = auth.getAuthority();

        // 권한 정보를 콘솔에 출력한다.
        System.out.println("[ROLE]]" + role);

        // username과 role을 넣어 JWT를 생성한다.
        String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L);

        // 응답 Header에 Bearer Token을 담아 내려준다.
        response.addHeader("Authorization", "Bearer " + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) {
        // 로그인 실패 시 401 상태 코드를 반환한다.
        response.setStatus(401);
    }
}

obtainUsername(request)와 obtainPassword(request)는 요청에서 로그인 값을 꺼내는 메서드이다.
Talend API Tester에서 username=dooly&password=1234 형태로 보내면 이 값들이 필터에서 추출된다.


UsernamePasswordAuthenticationToken은 인증에 사용할 사용자명과 비밀번호를 담는 객체이다.
이 객체를 AuthenticationManager에게 넘기면, Spring Security가 등록된 사용자 정보와 비밀번호를 비교해 인증을 진행한다.
정확히는 AuthenticationManager가 인증 처리를 위임하고, 등록된 인증 처리 흐름에서 사용자 조회와 비밀번호 검증이 이루어진다고 이해하면 된다.


successfulAuthentication()은 인증에 성공했을 때 실행된다.
이때 사용자명과 권한을 꺼내 JWT를 만들고, 응답 Header에 Authorization: Bearer 토큰값 형태로 내려준다.


권한은 authentication.getAuthorities()로 꺼낸다.
이 예제에서는 권한 목록에서 첫 번째 권한을 꺼내 role 값으로 사용한다.
실습에서는 가입 시 ADMIN 권한으로 저장했기 때문에 로그인에 성공하면 ADMIN 권한이 토큰에 들어간다.


즉, security11에서는 로그인 성공 결과로 화면을 이동하는 것이 아니라, 클라이언트가 이후 요청에 사용할 JWT를 받는다.


JWTFilter에서 토큰 검증하기

JWTFilter는 로그인 이후 요청에서 Authorization 헤더를 확인한다

JWTFilter는 이미 발급된 JWT를 검증하는 필터이다.
로그인 요청에서 토큰을 만드는 역할은 LoginFilter가 담당하고, 이후 요청에서 토큰을 확인하는 역할은 JWTFilter가 담당한다.


요청이 들어오면 JWTFilter는 먼저 Authorization 헤더를 찾는다.
헤더가 없거나 Bearer로 시작하지 않으면 토큰 기반 인증 요청이 아니라고 보고 다음 필터로 넘긴다.


헤더가 올바르면 Bearer 뒤의 실제 토큰만 꺼낸다.
그 다음 토큰 만료 여부를 확인한다.
토큰이 만료되지 않았다면 토큰에서 username과 role을 꺼내 인증 객체를 만들고, SecurityContextHolder에 저장한다.

// JWTFilter.java
package com.example.security11.jwt;

import com.example.security11.dto.CustomUserDetails;
import com.example.security11.entity.UserEntity;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 java.io.IOException;

public class JWTFilter extends OncePerRequestFilter {
    private final JWTUtil jwtUtil;

    public JWTFilter(JWTUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 요청 Header에서 Authorization 값을 찾는다.
        String authorization = request.getHeader("Authorization");

        // Authorization 헤더가 없거나 Bearer 형식이 아니면 다음 필터로 넘긴다.
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            System.out.println("token null");
            filterChain.doFilter(request, response);
            return;
        }

        // Bearer 뒤의 실제 JWT만 꺼낸다.
        String token = authorization.split(" ")[1];

        // 토큰이 만료되었으면 다음 필터로 넘긴다.
        if (jwtUtil.isExpired(token)) {
            System.out.println("token expired");
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰에서 사용자명과 권한을 꺼낸다.
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        // 토큰 정보로 임시 UserEntity를 만든다.
        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        userEntity.setPassword("temppassword");
        userEntity.setRole(role);

        // UserEntity를 Spring Security가 이해할 수 있는 CustomUserDetails로 감싼다.
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        // 인증 객체를 만든다.
        Authentication authToken = new UsernamePasswordAuthenticationToken(
                customUserDetails, null, customUserDetails.getAuthorities());

        // 현재 요청의 인증 정보를 SecurityContext에 저장한다.
        SecurityContextHolder.getContext().setAuthentication(authToken);

        // 다음 필터로 요청을 넘긴다.
        filterChain.doFilter(request, response);
    }
}

Authorization 헤더가 없을 때 바로 에러를 내지 않고 다음 필터로 넘기는 이유는 모든 요청이 토큰을 필요로 하는 것은 아니기 때문이다.
예를 들어 /, /join, /login은 인증 없이 접근할 수 있는 요청이다.
보호된 요청이라면 인증 정보가 등록되지 않은 상태로 다음 단계에 도달하고, 이후 SecurityConfig의 권한 규칙에 따라 접근이 막힐 수 있다.


authorization.split(" ")[1]은 Bearer 토큰값에서 실제 토큰 부분만 꺼낸다.
JWTUtil은 순수한 JWT 문자열을 검증해야 하므로 Bearer 접두어를 제거해야 한다.


토큰이 만료되었을 때도 이 코드에서는 바로 응답을 만들지 않고 다음 필터로 넘긴다.
이 경우 인증 객체가 SecurityContextHolder에 저장되지 않는다.
따라서 보호된 요청이라면 인증되지 않은 요청으로 처리될 수 있다.


토큰이 유효하면 username과 role을 꺼낸다.
그리고 이 정보로 CustomUserDetails와 UsernamePasswordAuthenticationToken을 만든다.
마지막으로 SecurityContextHolder에 인증 객체를 저장하면, 현재 요청은 인증된 사용자 요청으로 처리될 수 있다.


여기서 임시로 만든 UserEntity의 password에 "temppassword"를 넣는 이유는 이 단계에서 비밀번호 검증을 다시 하는 것이 아니기 때문이다.
이미 로그인할 때 비밀번호 검증은 끝났고, 지금은 JWT에서 꺼낸 사용자명과 권한으로 인증 객체를 복원하는 흐름이다.
따라서 여기서 중요한 값은 username과 role이다.


JWTFilter의 핵심은 요청 Header의 JWT를 읽고, 검증이 끝난 사용자 정보를 SecurityContextHolder에 등록하는 것이다.


Security11Application 실행과 user_entity 테이블 생성 확인

회원 정보를 저장할 테이블이 먼저 필요하다

security11에서는 로그인할 사용자를 DB에 저장한다.
사용자가 로그인하면 서버는 전달받은 username으로 사용자를 조회하고, 저장된 비밀번호와 요청 비밀번호를 비교한다.


따라서 먼저 회원 정보를 저장할 테이블이 필요하다.
이 예제에서는 user_entity 테이블이 생성된다.
테이블에는 사용자 식별값, 아이디, 암호화된 비밀번호, 권한 정보가 저장된다.


user_entity 테이블의 주요 컬럼은 다음과 같다.

  • id: 회원을 구분하는 기본 키이다.
  • username: 로그인할 때 사용하는 사용자명이다.
  • password: 암호화되어 저장되는 비밀번호이다.
  • role: 사용자의 권한 정보이다.

여기서 role은 뒤에서 JWT의 Payload에도 들어갈 수 있다.
서버는 토큰 안의 권한 정보를 바탕으로 사용자가 특정 요청에 접근할 수 있는지 판단할 수 있다.

Security11Application을 실행하면 Hibernate가 user_entity 테이블을 생성한다.
이 테이블에는 회원의 id, username, password, role 값이 저장된다.
이후 /join 요청으로 가입한 회원 정보가 이 테이블에 저장되고, 로그인할 때 이 정보를 기준으로 사용자 인증이 진행된다.


join 요청으로 사용자 등록하기

로그인하려면 먼저 사용자가 DB에 저장되어 있어야 한다

/join 요청은 회원가입 요청이다.
로그인을 테스트하려면 먼저 로그인할 사용자를 만들어야 한다.
그래서 Talend API Tester에서 /join 주소로 사용자 정보를 전송한다.


이 예제에서는 JSON이 아니라 application/x-www-form-urlencoded 형식으로 데이터를 보낸다.
application/x-www-form-urlencoded는 폼 데이터처럼 key=value&key=value 형태로 값을 전달하는 방식이다.


/join 요청은 JoinController가 처리한다.
요청으로 들어온 username, password는 JoinDTO에 담기고, JoinService가 실제 저장 흐름을 진행한다.

// JoinController.java
package com.example.security11.controller;

import com.example.security11.dto.JoinDTO;
import com.example.security11.service.JoinService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@ResponseBody
public class JoinController {
    private final JoinService joinService;

    public JoinController(JoinService joinService) {
        this.joinService = joinService;
    }

    @PostMapping("/join")
    public String joinProcess(@ModelAttribute JoinDTO joinDTO) {
        // 요청 데이터를 서비스로 넘겨 회원가입을 처리한다.
        joinService.joinProcess(joinDTO);
        // 가입 성공 결과를 문자열로 응답한다.
        return "ok";
    }
}

@Controller는 요청을 처리하는 컨트롤러 클래스라는 뜻이다.
원래 @Controller는 화면 이름을 반환하는 방식으로도 사용할 수 있다.
하지만 이 코드에는 @ResponseBody가 함께 붙어 있기 때문에, 반환 문자열 "ok"가 화면 이름이 아니라 응답 Body에 그대로 들어간다.


@ModelAttribute는 요청 파라미터를 객체에 담아 주는 역할을 한다.
username=dooly&password=1234처럼 폼 데이터가 들어오면, JoinDTO의 username, password 필드에 값이 들어간다.


요청 데이터는 아래처럼 볼 수 있다.

// /join 요청 Body
username=dooly&password=1234

username은 가입할 사용자명이다.
password는 사용자가 입력한 원문 비밀번호이다.
서버는 이 비밀번호를 그대로 저장하지 않고 암호화한 뒤 저장한다.

// JoinService.java
package com.example.security11.service;

import com.example.security11.dto.JoinDTO;
import com.example.security11.entity.UserEntity;
import com.example.security11.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class JoinService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public JoinService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.userRepository = userRepository;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    public void joinProcess(JoinDTO joinDTO) {
        // 가입할 사용자명을 꺼낸다.
        String username = joinDTO.getUsername();
        // 가입할 비밀번호를 꺼낸다.
        String password = joinDTO.getPassword();

        // 같은 username이 이미 있는지 확인한다.
        Boolean isExist = userRepository.existsByUsername(username);
        if (isExist) {
            return;
        }

        // DB에 저장할 회원 엔티티를 만든다.
        UserEntity data = new UserEntity();
        data.setUsername(username);
        // 비밀번호는 BCrypt로 암호화해서 저장한다.
        data.setPassword(bCryptPasswordEncoder.encode(password));
        // 실습에서는 ADMIN 권한으로 저장한다.
        data.setRole("ADMIN");

        // 회원 정보를 DB에 저장한다.
        userRepository.save(data);
    }
}

JoinService에서 가장 중요한 부분은 비밀번호 저장 방식이다.
data.setPassword(bCryptPasswordEncoder.encode(password))는 사용자가 입력한 원문 비밀번호를 암호화해서 저장한다.
그래서 사용자가 1234를 입력해도 DB에는 1234가 그대로 들어가지 않는다.


또 실습에서는 data.setRole("ADMIN")으로 권한을 저장한다.
이 권한은 로그인 성공 후 JWT의 role 값으로 들어가고, 이후 /admin 요청 권한 확인에 사용된다.


userRepository.existsByUsername(username)은 같은 사용자명이 이미 저장되어 있는지 확인한다.
이미 같은 username이 있으면 return으로 저장 흐름을 끝낸다.
즉, 중복 사용자는 새로 저장하지 않는다.

/join 요청은 POST 방식으로 회원가입 데이터를 서버에 전달한다.
요청 Body에는 username=dooly&password=1234 값이 들어가며, Content-Type은 application/x-www-form-urlencoded 형식이다.
서버가 요청을 정상 처리하면 응답 Body에 ok가 출력된다.


user_entity 테이블 저장 결과 확인

비밀번호는 원문이 아니라 암호화된 값으로 저장된다

회원가입이 성공하면 user_entity 테이블에 사용자가 저장된다.
이때 가장 중요한 점은 비밀번호가 원문 그대로 저장되지 않는다는 것이다.


예를 들어 사용자가 1234를 입력했다고 해도 DB에는 1234가 그대로 들어가면 안 된다.
비밀번호가 원문으로 저장되면 DB가 노출되었을 때 모든 사용자의 비밀번호가 바로 드러나기 때문이다.


그래서 서버는 비밀번호를 암호화해서 저장한다.
이 예제에서는 저장된 비밀번호가 $2a$10$... 형태로 보인다.
이 형태는 BCrypt 방식으로 암호화된 비밀번호에서 자주 볼 수 있다.


또 role에는 ADMIN이 저장되어 있다.
이 권한은 이후 로그인 성공 후 생성되는 JWT에 포함되고, /admin 요청을 허용할지 판단하는 데 사용될 수 있다.

user_entity 테이블을 조회하면 dooly 사용자가 저장된 것을 확인할 수 있다.
비밀번호는 원문 1234가 아니라 $2a$10$... 형태의 암호화된 값으로 저장된다.
role에는 ADMIN이 저장되어 있으므로, 이후 관리자 요청을 보낼 때 이 사용자의 권한 정보가 JWT에 포함될 수 있다.


기본 페이지 접근 확인

모든 요청이 인증을 요구하는 것은 아니다

애플리케이션에는 인증 없이 접근할 수 있는 요청도 있고, 인증이 필요한 요청도 있다.
기본 페이지는 인증 없이 접근 가능한 요청으로 볼 수 있다.


예를 들어 브라우저에서 기본 주소로 접속했을 때 Main Controller 응답이 나온다.
이 요청은 JWT를 보내지 않아도 접근할 수 있다.


반면 뒤에서 확인할 /admin 요청은 보호된 요청이다.
보호된 요청은 아무나 접근하면 안 되므로, 서버가 요청에 포함된 JWT와 권한을 확인해야 한다.


이렇게 기본 페이지와 관리자 페이지를 비교하면 JWT가 필요한 요청과 필요하지 않은 요청의 차이를 이해하기 쉽다.

브라우저에서 기본 주소로 접속하면 Main Controller 응답이 출력된다.
이 화면은 로그인이나 JWT 없이도 접근 가능한 기본 요청을 확인하는 결과이다.
이후 /admin 요청은 토큰을 가진 사용자만 접근하는 흐름으로 비교해서 볼 수 있다.


login 요청과 JWT 발급 결과

로그인 성공 시 Authorization Header에 Bearer Token이 내려온다

/login 요청은 로그인 요청이다.
사용자가 username과 password를 보내면, LoginFilter가 이 값을 받아 인증을 시도한다.


이 예제에서는 username=dooly, password=1234로 로그인 요청을 보낸다.
서버는 DB에서 dooly 사용자를 찾고, 요청 비밀번호와 저장된 암호화 비밀번호를 비교한다.
인증에 성공하면 JWT를 생성한다.


생성된 JWT는 응답 Body가 아니라 응답 Header의 Authorization에 담긴다.
형식은 아래와 같다.

// 로그인 성공 응답 Header
Authorization: Bearer JWT토큰값

여기서 Bearer는 인증 타입이다.
그 뒤에 실제 JWT 문자열이 붙는다.

/login 요청은 POST 방식으로 로그인 데이터를 전달한다.
요청 Body에는 username=dooly&password=1234가 들어간다.
로그인에 성공하면 응답 Header의 Authorization에 Bearer Token이 담겨 내려온다.
응답 Body 영역에 No Content가 보이더라도 실패가 아니다.
이 화면의 응답 상태 코드는 200이고, 토큰이 응답 본문이 아닌 응답 헤더에 들어간 것이다.


발급된 JWT 구조 확인

JWT 안에는 사용자 정보와 권한 정보가 들어간다

로그인 성공 후 받은 JWT를 디코딩하면 내부 구조를 확인할 수 있다.
JWT는 Header, Payload, Signature로 구성된다.


Header에는 어떤 알고리즘으로 서명했는지 들어간다.
이 예제에서는 HS256 알고리즘 정보가 보인다.


Payload에는 실제 인증에 필요한 정보가 들어간다.
예를 들어 username, role, iat, exp 값이 들어간다.


각 값은 다음과 같이 이해하면 된다.

  • username: 로그인한 사용자명이다.
  • role: 사용자의 권한 정보이다.
  • iat: 토큰 발급 시간이다.
  • exp: 토큰 만료 시간이다.

Payload는 암호화된 공간이 아니다.
디코딩하면 내용을 확인할 수 있다.
따라서 권한이나 사용자명처럼 인증 판단에 필요한 정보는 넣을 수 있지만, 비밀번호 같은 민감한 정보는 넣으면 안 된다.

로그인 성공 후 받은 JWT를 디코딩하면 Header와 Payload를 확인할 수 있다.
Header에는 HS256 알고리즘 정보가 들어가고, Payload에는 username, role, iat, exp 값이 들어간다.
여기서 username은 로그인 사용자이고, role은 사용자의 권한 정보이다.
iat는 발급 시간, exp는 만료 시간이다.
Invalid Signature가 보일 수 있지만, 이 화면은 토큰 구조와 Payload 값을 확인하기 위한 결과물로 보면 된다.
토큰 형식은 읽을 수 있어도, 검증용 Secret Key가 코드에서 사용한 값과 다르면 서명 검증은 실패할 수 있다.


JWT를 요청 Header에 담아 admin 접근하기

보호된 요청에는 발급받은 토큰을 다시 보내야 한다

/admin 요청은 보호된 요청이다.
즉, 아무나 접근할 수 있는 요청이 아니라 인증과 권한 확인이 필요한 요청이다.


로그인에 성공하면 서버는 JWT를 발급한다.
하지만 토큰을 발급받았다고 해서 서버가 자동으로 모든 요청을 기억하는 것은 아니다.
security11은 세션을 사용하지 않는 STATELESS 방식이므로, 클라이언트는 보호된 요청을 보낼 때마다 발급받은 토큰을 요청 Header에 담아 보내야 한다.


요청 형식은 아래와 같다.

// /admin 요청 Header
Authorization: Bearer JWT토큰값

JWTFilter는 이 값을 읽고 Bearer 뒤의 실제 토큰을 꺼낸다.
그 다음 토큰의 서명과 만료 시간을 검증하고, Payload의 username과 role을 꺼내 인증 객체를 만든다.
이 인증 객체가 SecurityContextHolder에 저장되면 현재 요청은 인증된 사용자 요청으로 처리된다.


그 다음 /admin 요청은 SecurityConfig의 권한 규칙을 거친다.
/admin은 ADMIN 권한이 필요하도록 설정되어 있으므로, 인증 객체에 ADMIN 권한이 들어 있어야 접근할 수 있다.
즉, JWTFilter는 인증 정보를 복원하고, SecurityConfig의 권한 규칙이 최종 접근 가능 여부를 판단한다.

/admin 요청은 GET 방식으로 전송한다.
요청 Header의 Authorization에 /login에서 받은 Bearer Token을 담아 보내면 서버는 토큰을 검증한다.
토큰이 유효하고 인증 객체에 ADMIN 권한이 들어 있으면 Admin Controller 응답이 출력된다.
즉, 이 결과는 로그인 성공 후 발급받은 JWT가 보호된 요청 접근에 사용되는 흐름을 보여준다.


security11 JWT 필터 로그인 흐름 핵심 정리

security11에서는 Spring Security 필터 기반으로 로그인과 인증 확인 흐름을 처리한다.
/join 요청으로 사용자를 저장하고, 저장된 사용자의 비밀번호는 암호화되어 DB에 들어간다.


SecurityConfig는 /login, /, /join 요청을 허용하고, /admin 요청에는 ADMIN 권한을 요구한다.
또 LoginFilter를 UsernamePasswordAuthenticationFilter 위치에 넣고, JWTFilter를 LoginFilter 앞에 넣는다.


/login 요청이 들어오면 LoginFilter가 사용자명과 비밀번호를 받아 인증을 시도한다.
인증에 성공하면 서버는 JWT를 생성하고, 응답 Header의 Authorization에 Bearer Token을 담아 내려준다.


클라이언트는 보호된 요청을 보낼 때 이 토큰을 다시 요청 Header에 담아 보낸다.
서버는 JWTFilter에서 토큰의 서명과 만료 시간을 검증하고, 토큰에서 꺼낸 사용자명과 권한으로 인증 상태를 복원한다.
그 다음 SecurityConfig의 권한 규칙에 따라 /admin 접근 가능 여부가 결정된다.


security11의 핵심은 LoginFilter가 로그인 성공 시 JWT를 발급하고, JWTFilter가 이후 요청에서 JWT를 검증해 인증 상태를 복원한다는 점이다.




security12: Spring Security와 JPA 기반 인증/인가 예제

security12는 Spring Security와 JPA를 함께 사용해서 로그인 인증과 권한별 페이지 접근 제어를 확인하는 예제이다.
앞의 security11은 JWT를 발급하고 요청 Header의 토큰을 검증하는 흐름이 중심이었다.
이번 예제는 DB에 저장된 회원 정보를 기준으로 로그인하고, 회원의 권한에 따라 접근 가능한 화면이 달라지는 흐름을 확인한다.


이 예제에서 중요한 흐름은 두 가지이다.
첫째, 사용자가 로그인하면 MyUserDetailService가 DB에서 회원 정보를 조회하고 Spring Security가 비밀번호를 검증한다.
둘째, 로그인한 사용자가 특정 페이지에 접근하면 @PreAuthorize가 사용자의 권한을 확인한다.


security12의 핵심은 JPA로 저장된 회원 정보를 Spring Security 인증에 연결하고, ADMIN과 USER 권한에 따라 접근 가능한 화면을 나누는 것이다.


인증과 인가의 차이

인증은 사용자가 누구인지 확인하는 과정이다

인증은 사용자가 누구인지 확인하는 과정이다.
로그인 화면에서 아이디와 비밀번호를 입력하는 이유가 바로 인증을 하기 위해서이다.
서버는 사용자가 보낸 아이디를 기준으로 가입된 회원인지 찾고, 비밀번호가 맞는지 확인한다.


예를 들어 사용자가 duke@java.com과 비밀번호를 입력했다고 생각하면 된다.
서버는 securitymember 테이블에서 duke@java.com 회원을 찾는다.
회원이 있고 비밀번호도 맞으면 서버는 이 사용자를 로그인한 사용자로 인정한다.


인증에 성공하면 이후 요청에서 사용자를 구분할 수 있는 상태가 만들어진다.
세션 기반 인증에서는 세션 정보가 만들어질 수 있고, 토큰 기반 인증에서는 토큰이 내려갈 수 있다.
이번 security12는 기본 로그인 흐름을 사용하므로 세션 기반 로그인 흐름으로 동작한다고 이해하면 된다.

인증은 사용자가 누구인지 확인하는 과정이다.
사용자가 아이디와 비밀번호를 보내면 서버는 가입된 계정이 맞는지 확인한다.
확인에 성공하면 서버는 세션 ID 또는 토큰처럼 이후 요청에서 사용자를 증명할 수 있는 값을 내려줄 수 있다.


인가는 인증된 사용자의 권한을 확인하는 과정이다

인가는 로그인한 사용자가 특정 기능이나 화면에 접근할 권한이 있는지 확인하는 과정이다.
인증이 “누구인지 확인”이라면, 인가는 “그 사용자가 이 기능을 사용할 수 있는지 확인”이다.


예를 들어 duke@java.com이 로그인에 성공했다고 생각하면 된다.
로그인에 성공했다는 사실만으로 모든 페이지에 접근할 수 있는 것은 아니다.
ADMIN 권한이 필요한 페이지는 ADMIN 권한이 있어야 들어갈 수 있고, USER 권한이 필요한 페이지는 USER 권한이 있어야 들어갈 수 있다.


이 예제에서는 /view/adminpage와 /view/userpage를 권한별로 나누어 확인한다.
ADMIN 권한 사용자는 관리자 페이지에 접근할 수 있지만, USER 전용 페이지에는 접근하지 못한다.
반대로 USER 권한 사용자는 사용자 페이지에 접근할 수 있지만, 관리자 페이지에는 접근하지 못한다.


여기서 중요한 점은 이 예제의 권한 구조가 “관리자는 모든 페이지 접근 가능”으로 설계된 것이 아니라는 점이다.
현재 코드에서는 관리자 페이지는 ADMIN 권한만 요구하고, 사용자 페이지는 USER 권한만 요구한다.
따라서 ADMIN 계정이라도 USER 권한을 따로 가지고 있지 않으면 USER 전용 페이지 접근이 거부된다.

인가는 인증된 사용자가 특정 기능이나 화면에 접근할 수 있는 권한이 있는지 확인하는 과정이다.
사용자가 세션 ID나 토큰을 보내더라도, 해당 사용자에게 필요한 권한이 없으면 서버는 요청을 거부할 수 있다.


security12에서 확인할 전체 흐름

DB 회원 정보로 인증하고 권한으로 화면 접근을 나눈다

security12에서는 회원 정보를 securitymember 테이블에 저장한다.
저장된 회원은 userid, pw, roles 값을 가진다.
userid는 로그인할 때 사용하는 계정명이고, pw는 암호화된 비밀번호이며, roles는 사용자의 권한이다.


전체 흐름은 다음과 같다.

  • AdminAccountCreateTest로 ADMIN 계정을 먼저 저장한다.
  • securitymember 테이블에서 저장 결과를 확인한다.
  • SpringSecurityConfig에서 로그인 페이지, 허용 경로, 인증 실패/성공 흐름을 설정한다.
  • MyUserDetailService가 로그인 시도 계정을 DB에서 조회한다.
  • 로그인 성공 여부는 DB 조회 결과와 비밀번호 검증 결과로 결정된다.
  • ViewController에서 @PreAuthorize로 ADMIN 페이지와 USER 페이지 접근 권한을 나눈다.
  • ADMIN 계정과 USER 계정으로 각각 로그인해 접근 결과를 비교한다.

이 흐름을 보면 Spring Security가 단순히 로그인 화면만 제공하는 것이 아니라, DB 조회, 비밀번호 검증, 권한 확인까지 함께 연결한다는 점을 알 수 있다.


AdminAccountCreateTest로 ADMIN 계정 저장하기

테스트 코드로 관리자 계정을 먼저 준비한다

security12를 테스트하려면 먼저 로그인할 계정이 필요하다.
관리자 권한 테스트를 하기 위해 AdminAccountCreateTest에서 ADMIN 권한을 가진 계정을 저장한다.


이 테스트는 화면에서 회원가입하는 흐름이 아니다.
테스트 코드로 직접 Member 객체를 만들고, MemberRepository를 통해 DB에 저장하는 흐름이다.
이렇게 하면 관리자 계정을 미리 준비한 뒤 로그인과 권한 테스트를 진행할 수 있다.

// AdminAccountCreateTest.java
package com.example.security12;

import com.example.security12.entity.Member;
import com.example.security12.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
public class AdminAccountCreateTest {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private MemberRepository repository;

    @Test
    @Rollback(false)
    @Transactional
    void save() {
        // ADMIN 권한을 가진 회원 객체를 만든다.
        Member member = Member.createUser("duke@java.com", "9999", passwordEncoder, "ADMIN");

        // 회원 정보를 DB에 저장한다.
        repository.save(member);

        // 저장된 회원 전체를 조회한다.
        List<Member> list = repository.findAll();

        // 조회 결과를 콘솔에 출력한다.
        list.stream().forEach(System.out::println);
    }
}

Member.createUser()는 회원 객체를 만드는 정적 메서드이다.
여기서는 duke@java.com, 9999, ADMIN 값을 넘긴다.


중요한 점은 비밀번호 9999가 그대로 저장되지 않는다는 것이다.
PasswordEncoder가 비밀번호를 암호화한 뒤 Member 객체에 저장한다.
그래서 DB에는 원문 비밀번호가 아니라 $2a$10$... 형태의 암호화된 값이 들어간다.


@Rollback(false)는 테스트가 끝난 뒤 저장 결과를 되돌리지 않겠다는 뜻이다.
이 설정이 있어야 테스트로 저장한 관리자 계정이 DB에 남아 이후 로그인 테스트에 사용할 수 있다.

AdminAccountCreateTest를 실행하면 securitymember 테이블에 관리자 계정이 저장된다.
테스트 결과가 성공했고, Hibernate가 securitymember 테이블에 userid, pw, roles 값을 저장하는 insert 쿼리를 실행한 것을 확인할 수 있다.


securitymember 테이블 저장 결과 확인

저장된 회원은 userid, pw, roles를 가진다

securitymember 테이블은 로그인에 사용할 회원 정보를 저장한다.
이 테이블에서 중요한 컬럼은 userid, pw, roles이다.


userid는 로그인할 때 입력하는 계정명이다.
pw는 암호화된 비밀번호이다.
roles는 회원의 권한이다.


테스트로 저장한 관리자 계정을 조회하면 duke@java.com 계정과 ADMIN 권한을 확인할 수 있다.
비밀번호는 원문 9999가 아니라 BCrypt로 암호화된 문자열로 저장된다.

securitymember 테이블을 조회하면 duke@java.com 계정이 저장된 것을 확인할 수 있다.
비밀번호는 원문이 아니라 $2a$10$... 형태의 BCrypt 암호화 값으로 저장된다.
roles 값은 ADMIN이므로 이 계정은 관리자 권한을 가진다.


Member 엔티티와 Repository 구조

Member는 securitymember 테이블과 연결된다

Member는 securitymember 테이블과 연결되는 JPA Entity이다.
Entity는 데이터베이스 테이블과 연결되는 자바 객체이다.
즉, Member 객체 하나는 securitymember 테이블에 저장되는 회원 데이터 하나와 연결된다.

// Member.java
package com.example.security12.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.ToString;
import org.springframework.security.crypto.password.PasswordEncoder;

@Entity
@Table(name="securitymember")
@Getter
@ToString
public class Member {
    // 기본키이다.
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 로그인 계정명이다.
    @Column(unique = true)
    private String userid;

    // 암호화된 비밀번호이다.
    private String pw;

    // 권한 정보이다.
    private String roles;

    // 내부에서 회원 객체를 만들 때 사용하는 생성자이다.
    private Member(Long id, String userid, String pw, String roleUser) {
        this.id = id;
        this.userid = userid;
        this.pw = pw;
        this.roles = roleUser;
    }

    // JPA가 Entity를 만들 때 필요한 기본 생성자이다.
    protected Member() {}

    // 비밀번호를 암호화해서 회원 객체를 만든다.
    public static Member createUser(String userId, String pw, PasswordEncoder passwordEncoder, String role) {
        return new Member(null, userId, passwordEncoder.encode(pw), role);
    }
}

@Entity는 이 클래스가 JPA 엔티티라는 뜻이다.
@Table(name="securitymember")는 이 엔티티가 securitymember 테이블과 연결된다는 뜻이다.


createUser()는 회원 객체를 만들 때 사용하는 메서드이다.
이 메서드 안에서 passwordEncoder.encode(pw)를 호출한다.
따라서 회원을 만들 때부터 비밀번호가 암호화되어 저장된다.


roles 필드에는 ADMIN, USER 같은 권한 이름이 저장된다.
다만 Spring Security 내부에서는 ROLE_ADMIN, ROLE_USER처럼 ROLE_ 접두어가 붙은 권한으로 다뤄질 수 있다.
이 차이는 뒤에서 User.builder().roles()와 @PreAuthorize를 볼 때 중요하다.


MemberRepository는 Member 데이터를 조회하고 저장하는 저장소 역할을 한다.

// MemberRepository.java
package com.example.security12.repository;

import com.example.security12.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
    // userid로 회원을 조회한다.
    Optional<Member> findByUserid(String userId);
}

JpaRepository<Member, Long>은 Member 엔티티를 다루고, 기본키 타입은 Long이라는 뜻이다.
findByUserid()는 로그인할 때 계정명으로 회원을 찾기 위해 사용한다.


SpringSecurityConfig에서 보안 흐름 설정하기

어떤 요청을 허용하고 어떤 요청을 인증할지 정한다

SpringSecurityConfig는 security12의 보안 설정을 담당한다.
여기서 로그인 페이지 주소, 회원가입 허용 주소, 로그인 처리 주소, 로그인 성공/실패 처리, 로그아웃, 권한 검사 사용 여부를 설정한다.


가장 먼저 봐야 할 설정은 @EnableMethodSecurity이다.
이 설정이 있어야 컨트롤러 메서드에 붙인 @PreAuthorize가 동작한다.
즉, /view/adminpage, /view/userpage 같은 요청에서 메서드 단위 권한 검사를 할 수 있다.

// SpringSecurityConfig.java
package com.example.security12.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@Slf4j
@EnableMethodSecurity
public class SpringSecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호 암호화에 사용할 객체를 등록한다.
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer configure() throws Exception {
        // 정적 리소스 경로는 Spring Security Filter Chain에서 제외한다.
        return (web) -> web.ignoring().requestMatchers("/images/**", "/*.html", "/html/**");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests -> requests
                        // 로그인, 회원가입, 상태 확인 요청은 인증 없이 허용한다.
                        .requestMatchers("/status", "/view/login", "/view/signup", "/auth/signup").permitAll()
                        // 나머지 요청은 인증이 필요하다.
                        .anyRequest().authenticated())
                // csrf 기능을 비활성화한다.
                .csrf((csrf) -> csrf.disable())
                // form 로그인 설정이다.
                .formLogin(form -> form
                        // 직접 만든 로그인 화면 주소이다.
                        .loginPage("/view/login")
                        // 로그인 form이 전송될 처리 주소이다.
                        .loginProcessingUrl("/login-process")
                        // 로그인 계정 파라미터 이름이다.
                        .usernameParameter("userid")
                        // 로그인 비밀번호 파라미터 이름이다.
                        .passwordParameter("pw")
                        // 로그인 성공 시 실행된다.
                        .successHandler((request, response, authentication) -> {
                            log.info("*** 인증을 수행한 계정명 = {}", authentication.getName());
                            response.sendRedirect("/view/memberpage");
                        })
                        // 로그인 실패 시 실행된다.
                        .failureHandler((request, response, exception) -> {
                            log.info("*** 예외 메시지 = {}", exception.getMessage());
                            response.sendRedirect("/view/login");
                        }))
                // 기본 로그아웃 기능을 사용한다.
                .logout(withDefaults());

        // 설정한 보안 필터 체인을 반환한다.
        return http.build();
    }
}

PasswordEncoder는 비밀번호 암호화와 검증에 사용된다.
회원가입이나 테스트 계정 저장 때는 비밀번호를 암호화하고, 로그인 때는 사용자가 입력한 비밀번호와 암호화된 비밀번호가 맞는지 비교한다.


WebSecurityCustomizer는 특정 경로를 Spring Security Filter Chain에서 완전히 제외한다.
여기서는 /images/**, /*.html, /html/** 경로를 제외한다.
이 설정 때문에 정적 이미지나 특정 HTML 파일은 보안 필터를 거치지 않고 접근할 수 있다.


authorizeHttpRequests()는 요청별 접근 규칙을 정한다.
/status, /view/login, /view/signup, /auth/signup은 로그인하지 않아도 접근할 수 있다.
나머지 요청은 인증된 사용자만 접근할 수 있다.


formLogin()은 로그인 화면과 로그인 처리 주소를 설정한다.
loginPage("/view/login")은 로그인 화면 주소이다.
loginProcessingUrl("/login-process")는 실제 로그인 요청이 전송되는 주소이다.
usernameParameter("userid")와 passwordParameter("pw")는 로그인 폼에서 서버로 보내는 파라미터 이름을 지정한다.


여기서 userid와 pw를 지정했기 때문에 로그인 폼의 입력 이름도 이 값과 맞아야 한다.
만약 로그인 폼에서 username, password라는 이름으로 보내면 Spring Security가 현재 설정한 계정명과 비밀번호 값을 제대로 읽지 못할 수 있다.


정적 리소스 제외 설정과 경고 확인

web.ignoring은 보안 필터 체인 자체를 지나지 않게 한다

정적 리소스는 이미지, 일반 HTML, CSS, JavaScript처럼 화면을 구성하는 파일이다.
이런 파일까지 매번 인증 검사를 하면 불필요하게 복잡해질 수 있다.
그래서 실습에서는 일부 경로를 web.ignoring()으로 제외한다.


다만 이 설정을 사용하면 경고 메시지가 출력될 수 있다.
경고는 설정이 동작하지 않는다는 뜻이 아니다.
Spring Security가 최신 방식에서는 보안 필터 체인을 완전히 우회하기보다 permitAll()을 사용하는 방식을 권장한다는 의미로 보면 된다.

/images/**, /*.html, /html/** 경로를 web.ignoring()으로 제외했을 때 경고 메시지가 출력된다.
이 경고는 해당 방식이 동작하지 않는다는 뜻은 아니다.
다만 최신 방식에서는 web.ignoring()보다 HttpSecurity의 authorizeHttpRequests에서 permitAll()을 사용하는 것을 권장한다는 의미이다.

Spring Security가 특정 경로를 보안 필터에서 완전히 제외하도록 설정하면 이런 경고가 반복해서 출력될 수 있다.
실습에서는 정적 이미지와 HTML 파일 접근을 쉽게 확인하기 위해 사용했지만, 실제 서비스에서는 보안 필터를 완전히 우회할 경로인지 신중하게 판단해야 한다.


정적 리소스와 status 요청 확인

허용된 요청은 로그인 없이 접근할 수 있다

/images/spse.jpg는 정적 이미지 요청이다.
이 경로는 web.ignoring()으로 제외했기 때문에 로그인 없이 접근할 수 있다.


/status는 보안 설정에서 permitAll()로 허용한 요청이다.
따라서 로그인하지 않은 상태에서도 ok 응답을 받을 수 있다.


이 두 요청은 모두 로그인 없이 접근할 수 있지만 의미는 조금 다르다.
/images/spse.jpg는 보안 필터 체인을 아예 제외한 정적 리소스이고, /status는 보안 필터 설정 안에서 접근을 허용한 요청이다.

/images/spse.jpg 주소로 접근하면 Spring Security 관련 이미지가 정상 출력된다.
이 결과는 정적 리소스 경로가 로그인 없이 접근 가능하게 처리된 것을 보여준다.

/status 요청은 로그인하지 않아도 접근할 수 있는 상태 확인용 요청이다.
브라우저에서 /status로 접속하면 ok가 출력된다.
이 결과는 SpringSecurityConfig에서 /status를 permitAll()로 허용한 설정이 적용된 것을 보여준다.


인증이 필요한 요청은 로그인 화면으로 이동한다

localhost:9000 접속은 로그인 화면으로 연결된다

security12에서 대부분의 요청은 인증이 필요하다.
SpringSecurityConfig는 일부 허용 경로를 제외한 나머지 요청에 authenticated()를 적용한다.
authenticated()는 로그인한 사용자만 접근할 수 있다는 뜻이다.


그래서 허용되지 않은 요청을 로그인하지 않은 상태로 보내면 바로 원래 화면을 보여 주지 않는다.
먼저 로그인 여부를 확인하고, 로그인하지 않았다면 로그인 화면으로 이동시킨다.

localhost:9000으로 접속하면 인증이 필요한 요청으로 판단되어 로그인 페이지로 이동한다.
Spring Security가 적용되면 허용되지 않은 요청은 바로 화면을 보여 주는 것이 아니라, 먼저 로그인 여부를 확인한다.
로그인하지 않은 사용자는 인증을 위해 로그인 화면으로 이동한다.


MyUserDetailService로 DB 사용자 조회하기

로그인할 때 userid로 회원을 찾는다

사용자가 로그인 폼에 계정과 비밀번호를 입력하면 Spring Security는 사용자 정보를 조회해야 한다.
이때 사용되는 클래스가 MyUserDetailService이다.


MyUserDetailService는 UserDetailsService를 구현한다.
UserDetailsService는 Spring Security가 로그인할 사용자를 찾을 때 사용하는 규칙이다.
즉, Spring Security는 로그인 시도 계정명을 넘기고, 이 서비스가 해당 사용자의 정보를 찾아서 돌려주기를 기대한다.

// MyUserDetailService.java
package com.example.security12.config;

import com.example.security12.entity.Member;
import com.example.security12.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
@Slf4j
public class MyUserDetailService implements UserDetailsService {
    private final MemberService memberService;

    @Autowired
    public MyUserDetailService(MemberService memberService) {
        this.memberService = memberService;
    }

    @Override
    public UserDetails loadUserByUsername(String insertedUserId) throws UsernameNotFoundException {
        // 로그인 시도 계정으로 DB에서 회원을 찾는다.
        Optional<Member> findOne = memberService.findOne(insertedUserId);
        log.info("[로그인 시도] " + insertedUserId + findOne);

        // 회원이 없으면 인증 실패 예외를 발생시킨다.
        Member member = findOne.orElseThrow(() -> new UsernameNotFoundException("없는 회원입니다 ㅠ"));

        // Spring Security가 이해할 수 있는 UserDetails 객체로 변환한다.
        return User.builder()
                .username(member.getUserid())
                .password(member.getPw())
                .roles(member.getRoles())
                .build();
    }
}

loadUserByUsername()은 로그인할 때 자동으로 호출되는 메서드이다.
매개변수 insertedUserId에는 사용자가 로그인 폼에 입력한 계정명이 들어온다.


memberService.findOne(insertedUserId)는 DB에서 해당 계정의 회원 정보를 찾는다.
회원이 없으면 Optional.empty가 되고, UsernameNotFoundException이 발생한다.
이 경우 인증은 실패한다.


회원이 있으면 User.builder()로 Spring Security가 이해할 수 있는 UserDetails 객체를 만든다.
여기에는 사용자명, 암호화된 비밀번호, 권한이 들어간다.


여기서 roles(member.getRoles())가 중요하다.
DB의 roles 값은 ADMIN 또는 USER처럼 저장되어 있다.
User.builder().roles("ADMIN")처럼 설정하면 Spring Security 내부에서는 권한이 ROLE_ADMIN 형태로 만들어진다.
그래서 로그에는 ADMIN이 아니라 ROLE_ADMIN처럼 보일 수 있다.


DB에는 ADMIN, USER로 저장되어도 Spring Security 내부 권한은 ROLE_ADMIN, ROLE_USER 형태로 다뤄질 수 있다.


존재하지 않는 계정으로 로그인하면 실패한다

DB에서 회원을 찾지 못하면 인증이 실패한다

로그인할 때 입력한 계정이 securitymember 테이블에 없으면 인증은 실패한다.
서버는 해당 계정을 찾으려고 하지만 결과가 비어 있기 때문이다.


로그를 보면 Optional.empty가 출력된다.
이것은 조회 결과가 없다는 뜻이다.
그 다음 자격 증명에 실패했다는 메시지가 출력된다.

존재하지 않는 계정으로 로그인하면 MyUserDetailService가 DB에서 사용자를 찾지 못한다.
로그에는 Optional.empty가 출력되고, 이후 자격 증명에 실패했다는 메시지가 출력된다.
즉, 입력한 계정이 securitymember 테이블에 없으면 인증에 실패한다.


로그인 성공 흐름 확인

가입된 계정이면 인증이 완료된다

가입된 계정으로 로그인하면 MyUserDetailService가 DB에서 회원 정보를 찾는다.
그 다음 Spring Security는 사용자가 입력한 비밀번호와 DB에 저장된 암호화 비밀번호가 맞는지 비교한다.


비밀번호가 맞으면 인증에 성공한다.
인증에 성공하면 설정된 성공 처리 흐름에 따라 /view/memberpage로 이동한다.

가입된 계정으로 로그인하면 서버는 DB에서 사용자를 조회하고 비밀번호를 검증한다.
인증에 성공하면 로그인 이후 접근 가능한 화면으로 이동한다.
이 흐름은 MyUserDetailService가 DB 기반 사용자 정보를 조회하고, Spring Security가 인증을 완료하는 과정을 보여준다.

duke@java.com 계정으로 로그인하면 securitymember 테이블에서 회원 정보가 조회된다.
로그에는 인증을 수행한 계정명과 권한 정보가 출력된다.
DB에는 ADMIN으로 저장되어 있지만, Spring Security 내부에서는 ROLE_ADMIN 권한으로 다뤄지는 것을 확인할 수 있다.
이 계정은 관리자 권한을 가지고 있으므로 관리자 페이지 접근에 사용할 수 있다.


ViewController에서 권한별 화면 나누기

PreAuthorize로 메서드 실행 전에 권한을 검사한다

ViewController는 화면 이동 요청을 처리한다.
여기서 중요한 부분은 @PreAuthorize이다.
@PreAuthorize는 컨트롤러 메서드가 실행되기 전에 권한 조건을 검사한다.


즉, /view/adminpage 요청이 들어와도 바로 admin_role 화면을 반환하지 않는다.
먼저 현재 로그인 사용자가 ADMIN 권한을 가지고 있는지 확인한다.
권한이 있으면 메서드가 실행되고, 없으면 접근이 거부된다.

// ViewController.java
package com.example.security12.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.time.LocalDateTime;

@Controller
@RequestMapping("/view")
@Slf4j
public class ViewController {
    // 로그인 화면을 반환한다.
    @GetMapping("/login")
    public String loginPage() {
        return "login";
    }

    // 회원가입 화면을 반환한다.
    @GetMapping("/signup")
    public String joinPage() {
        return "signup_form";
    }

    // 로그인 후 공통 회원 페이지를 반환한다.
    @GetMapping("/memberpage")
    public String dashboardPage(@AuthenticationPrincipal User user) {
        log.info(user.getUsername()+"님("+user.getAuthorities()+")이 멤버페이지에 접근함 - "+ LocalDateTime.now());
        return "member_page";
    }

    // ADMIN 권한이 있는 사용자만 접근할 수 있다.
    @PreAuthorize("hasAnyRole('ADMIN')")
    @GetMapping("/adminpage")
    public String adminSettingPage() {
        return "admin_role";
    }

    // USER 권한이 있는 사용자만 접근할 수 있다.
    @PreAuthorize("hasAnyRole('USER')")
    @GetMapping("/userpage")
    public String userSettingPage() {
        return "user_role";
    }
}

@AuthenticationPrincipal은 현재 로그인한 사용자 정보를 꺼낼 때 사용한다.
dashboardPage()에서는 로그인한 사용자의 계정명과 권한을 로그로 출력한다.


@PreAuthorize("hasAnyRole('ADMIN')")는 ADMIN 권한이 있는 사용자만 메서드를 실행할 수 있다는 뜻이다.
@PreAuthorize("hasAnyRole('USER')")는 USER 권한이 있는 사용자만 메서드를 실행할 수 있다는 뜻이다.


여기서 hasAnyRole('ADMIN')은 내부적으로 ROLE_ADMIN 권한을 확인한다고 이해하면 된다.
마찬가지로 hasAnyRole('USER')는 내부적으로 ROLE_USER 권한을 확인한다.
따라서 User.builder().roles("ADMIN")로 만들어진 ROLE_ADMIN 권한과 hasAnyRole('ADMIN') 조건이 서로 연결된다.


@PreAuthorize는 로그인 여부만 보는 것이 아니라, 현재 사용자가 필요한 권한을 가지고 있는지까지 확인한다.


ADMIN 계정 권한 테스트

ADMIN은 adminpage에 접근할 수 있다

duke@java.com 계정은 ADMIN 권한을 가지고 있다.
Spring Security 내부에서는 이 권한이 ROLE_ADMIN으로 다뤄진다.
따라서 로그인 후 /view/adminpage에 접근하면 관리자 화면이 정상 출력된다.

ADMIN 권한을 가진 사용자로 로그인한 뒤 /view/adminpage에 접근하면 관리자 화면이 정상 출력된다.
이 화면은 @PreAuthorize("hasAnyRole('ADMIN')") 조건을 통과한 결과이다.


ADMIN은 userpage에 접근하지 못한다

ADMIN으로 로그인했다고 해서 USER 전용 페이지까지 모두 접근할 수 있는 것은 아니다.
현재 설정에서는 /view/userpage가 USER 권한을 요구한다.


따라서 ADMIN 권한만 가진 사용자가 /view/userpage에 접근하면 인가 단계에서 거부된다.
이때 브라우저에는 403 Forbidden 오류가 보일 수 있다.

ADMIN 권한 계정으로 /view/userpage에 접근하면 403 Forbidden이 발생한다.
이 페이지는 USER 권한이 필요한 페이지이기 때문이다.
로그인은 되어 있어도, 필요한 권한이 없으면 인가 단계에서 접근이 거부된다.


USER 계정 생성과 DB 저장 확인

회원가입으로 USER 권한 계정을 만든다

관리자 계정은 테스트 코드로 만들었다.
이번에는 일반 사용자 권한을 가진 계정을 회원가입 흐름으로 만든다.


회원가입 요청은 /auth/signup으로 처리된다.
SignupController가 요청 데이터를 받고, RegisterMemberService가 회원을 저장한다.
이때 기본 권한은 USER로 저장된다.

// SignupController.java
package com.example.security12.controller;

import com.example.security12.dto.MemberSignupDTO;
import com.example.security12.service.RegisterMemberService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class SignupController {
    private final RegisterMemberService registerMemberService;

    public SignupController(RegisterMemberService registerMemberService) {
        this.registerMemberService = registerMemberService;
    }

    @PostMapping("/signup")
    public ResponseEntity<String> join(MemberSignupDTO dto) {
        try {
            // 회원가입을 처리한다.
            registerMemberService.join(dto.getUserid(), dto.getPw());
            // 성공 메시지를 반환한다.
            return ResponseEntity.ok("성공적으로 회원 가입이 진행되었습니다.");
        } catch (Exception e) {
            // 실패하면 오류 메시지를 반환한다.
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
}

SignupController는 @RestController이므로 반환 값이 화면 이름이 아니라 응답 Body로 내려간다.
회원가입에 성공하면 성공 메시지가 응답 본문으로 내려간다.


join(MemberSignupDTO dto)에는 별도의 @RequestBody가 붙어 있지 않다.
따라서 이 흐름은 JSON 요청보다 폼 데이터 요청과 연결해서 이해하는 것이 좋다.
요청 파라미터의 userid, pw 값이 MemberSignupDTO에 담기고, 서비스로 전달된다.

// RegisterMemberService.java
package com.example.security12.service;

import com.example.security12.entity.Member;
import com.example.security12.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class RegisterMemberService {
    private final PasswordEncoder passwordEncoder;
    private final MemberRepository repository;

    @Autowired
    public RegisterMemberService(PasswordEncoder passwordEncoder, MemberRepository repository) {
        this.passwordEncoder = passwordEncoder;
        this.repository = repository;
    }

    public Long join(String userid, String pw) {
        // USER 권한을 가진 회원 객체를 만든다.
        Member member = Member.createUser(userid, pw, passwordEncoder, "USER");
        // 중복 회원인지 검사한다.
        validateDuplicateMember(member);
        // 회원 정보를 DB에 저장한다.
        repository.save(member);

        // 저장된 회원의 id를 반환한다.
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        // 같은 userid가 있으면 예외를 발생시킨다.
        repository.findByUserid(member.getUserid())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }
}

RegisterMemberService는 Member.createUser(userid, pw, passwordEncoder, "USER")를 호출한다.
그래서 회원가입으로 만든 계정은 기본적으로 USER 권한을 가진다.
비밀번호는 관리자 계정과 마찬가지로 암호화되어 저장된다.

USER 권한을 가진 계정을 새로 만드는 흐름이다.
회원가입 화면에서 사용자 정보를 입력하면 서버는 회원 정보를 저장한다.
이후 securitymember 테이블을 조회하면 기존 ADMIN 계정과 새로 만든 USER 계정이 함께 저장된 것을 확인할 수 있다.

securitymember 테이블을 조회하면 duke@java.com은 ADMIN, unico@java.com은 USER 권한으로 저장되어 있다.
같은 테이블에 회원 정보가 저장되지만, roles 값에 따라 접근할 수 있는 화면이 달라진다.


USER 계정 권한 테스트

USER 계정으로 로그인한다

USER 권한을 가진 unico@java.com 계정으로 로그인한다.
로그인에 성공하면 Spring Security는 securitymember 테이블에서 조회한 회원 정보를 기준으로 인증 상태를 만든다.
하지만 인증에 성공했다는 사실만으로 모든 화면에 접근할 수 있는 것은 아니다.


이후 요청에서는 현재 로그인 사용자의 권한이 함께 확인된다.
USER 권한 사용자는 USER 전용 페이지에는 접근할 수 있지만, ADMIN 권한이 필요한 페이지에는 접근할 수 없다.

USER 권한 계정으로 로그인하는 흐름이다.
로그인 폼에 USER 계정 정보를 입력하면 서버는 DB에서 회원 정보를 조회하고 비밀번호를 검증한다.
인증에 성공하면 로그인 상태가 만들어지고, 이후 권한별 페이지 접근 테스트를 진행할 수 있다.


USER는 adminpage에 접근하지 못한다

USER 권한 사용자는 관리자 페이지에 접근할 수 없다.
관리자 페이지는 @PreAuthorize("hasAnyRole('ADMIN')") 조건이 붙어 있기 때문이다.

USER 권한 계정으로 /view/adminpage에 접근하면 403 Forbidden이 발생한다.
사용자는 로그인되어 있지만 ADMIN 권한이 없기 때문에 관리자 페이지 접근이 거부된다.


USER는 userpage에 접근할 수 있다

USER 권한 사용자는 /view/userpage에 접근할 수 있다.
이 페이지는 @PreAuthorize("hasAnyRole('USER')") 조건이 붙어 있기 때문이다.

USER 권한 계정으로 /view/userpage에 접근하면 사용자 전용 화면이 정상 출력된다.
이 화면은 @PreAuthorize("hasAnyRole('USER')") 조건을 통과한 결과이다.


security12 핵심 정리

security12는 JPA로 저장된 회원 정보를 Spring Security 로그인 인증에 연결하는 예제이다.
회원 정보는 securitymember 테이블에 저장되고, 비밀번호는 BCrypt로 암호화된다.


로그인할 때는 MyUserDetailService가 사용자가 입력한 userid로 회원을 조회한다.
회원이 없으면 인증에 실패하고, 회원이 있으면 Spring Security가 입력 비밀번호와 암호화 비밀번호를 비교한다.


인증에 성공하면 사용자는 로그인 상태가 된다.
하지만 로그인 상태만으로 모든 화면에 접근할 수 있는 것은 아니다.
ViewController의 @PreAuthorize가 ADMIN 권한 또는 USER 권한을 검사한다.


DB에는 권한이 ADMIN, USER처럼 저장되지만, Spring Security 내부에서는 ROLE_ADMIN, ROLE_USER 형태로 다뤄질 수 있다.
hasAnyRole('ADMIN')은 내부적으로 ROLE_ADMIN 권한을 확인하고, hasAnyRole('USER')는 내부적으로 ROLE_USER 권한을 확인한다.


ADMIN 사용자는 관리자 페이지에 접근할 수 있지만 USER 전용 페이지에는 접근하지 못한다.
USER 사용자는 사용자 페이지에 접근할 수 있지만 관리자 페이지에는 접근하지 못한다.


security12의 핵심은 인증은 DB 회원 정보로 사용자를 확인하는 과정이고, 인가는 로그인한 사용자의 권한으로 접근 가능한 화면을 결정하는 과정이라는 점이다.

0개의 댓글