원래는 spring-security랑 쿠키에 넣는거 까지 다 끝낼 심산이었는데
넘 바쁨 이슈로 걍 jwt만 완성했다...... 본 프로젝트 시작하기전에 후다닥..한번 시간내서 해바야겟다.. 흐극.. ㅠ
요약
1. pom.xml에 jjwt 관련 의존성 추가
↓
2. JwtTokenProvider 클래스에서 토큰 생성, 검증, 파싱 로직 작성
↓
3. 서비스 계층에서 로그인 성공 시 토큰 생성
↓
4. 컨트롤러에서 JWT를 응답 Header에 담아 클라이언트에게 전달
↓
5. 클라이언트는 이후 요청 시 Authorization: Bearer <token> 형태로 요청
- JWT 의존성 추가
- jjwt 라이브러리를 `pom.xml`에 추가
- JWT 토큰 발급 로직 구현
- `JwtTokenProvider` 클래스 생성
- `secretKey` 초기화 (`@PostConstruct`에서 Base64 인코딩)
- createToken() 메서드로 userId, roles 등 Claims를 담아 토큰 생성
- `JwtTokenProvider` 클래스 생성
- 로그인 성공 시 JWT 토큰 발급
- loginUser() 서비스 메서드에서 사용자 ID, 비밀번호 확인 후
- JwtTokenProvider.createToken()을 호출해 JWT 토큰 생성
- 생성된 토큰을 응답 header에 담아 클라이언트로 반환
- ApiResponse에 토큰 포함
- 클라이언트가 이후 API 요청 시 토큰을 활용할 수 있도록 Header로 반환했음
- JWT 활용 예정
- 이후 글 작성, 수정, 삭제 등 인증이 필요한 API 요청 시,
- 클라이언트는 Authorization: Bearer <token> 형식으로 Header에 토큰 포함시킴
JWT란?
서버가 사용자가 인증되었다는것을 증명하기 위해 만들어서 클라이언트에게 보내주는 디지털 서명된 토큰이다
로그인한 사용자들의 신분증 같은것..
📍 사용이유
JWT는 `stateless` 방식의 HTTP 통신과 잘 어울리고 RESTful API 설계에 적합하다.
JWT는 클라이언트 측에서 관리되는 토큰이기때문에 서버는 클라이언트의 상태를 유지할 필요가 없어 확장성이 뛰어나고, 분산 시스템 환경에서 유리하다.
- 서버가 사용자의 로그인 상태를 기억하지 않아도 됨 (stateless)
- 서버가 여러 대여도 문제없음 (스케일 아웃 가능)
- 서명되어 있어서 위조 방지 가능
| 구분 | 세션 세션 | JWT |
| 서버에 사용자 상태 저장 | O (세션 저장소 필요) | ❌ 없음 (토큰에 상태 자체를 담음) |
| 확장성 | 낮음 (서버가 많아지면 공유 문제 발생) | 높음 (토큰만 있으면 인증됨) |
| 클라이언트 인증 | 세션 ID 쿠키 사용 | JWT 토큰을 요청 Header에 실어 보냄 |
📍 주의해야할 점
- 토큰 자체가 클라이언트에 저장되므로 유출되면 위험
- 토큰을 즉시 무효화할 수 없음 (세션처럼 삭제가 안 됨, 만료될 때까지 기다려야 함)
- 토큰 길이가 길어서 요청마다 네트워크 트래픽 증가
토큰 탈취
JWT는 클라이언트에 저장되기 때문에 탈취될 경우 대처가 어렵다.
그렇기때문에 HTTPS 사용 및 적절한 만료 시간 설정 등이 필요하다구한다.
📍 사용이유
JWT는 . 점(.) 으로 구분된 3개의 문자열로 이루어져 있다.
Header, Payload, Signature 로 나눠져있는데 원래는 Header와 Payload는 JSON 형식의 데이터이다.
json을 직접 주고받으면 깨질수있어서 웹에서 안전하게 주고받으려고
서버가 알아서 Base64인코딩해서 문자열로 . 으로 이어서 붙힌거다
그리고 넘기니까 압축해서 보내는것이고 그래서 이거는 암호화의 개념이아니다.
걍 누구나 디코딩하면 볼수있는것..
HTTP이예민한자식.. 특수문자에 초민감함.. 잘깨짐 ㅜㅜ
그리구 넘 기니까
Header.Payload.Signature
//{"alg":"HS256","typ":"JWT"} 이걸 Base64 인코딩 한것
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
//{"sub":"userId123","iat":1688888888,"exp":1688892488} Base64 인코딩 한것
eyJzdWIiOiJ1c2VySWQxMjMiLCJpYXQiOjE2ODg4ODg4ODgsImV4cCI6MTY4ODg5MjQ4OH0
.
qgAWuEi0TCcQ9sU2KwTezyX8r5_jA6ODaA4K3RW5kqY
| 부분 | 이름 | 설명 |
| ① | Header | 토큰의 타입(JWT)과 서명 알고리즘 (HS256 등) |
| ② | Payload | 실제 사용자 정보 (Claims라고 함) |
| ③ | Signature | 이 토큰이 위조되지 않았음을 증명하는 서명값 |
① Header
- `alg`: 사용할 서명 알고리즘 (예: HMAC-SHA256)
- `typ`: 타입은 JWT라는 뜻
{
"alg": "HS256",
"typ": "JWT"
}
② Payload (Claims)
Claims = 걍 토큰안에 담긴 정보들 (ex : 사용자 ID, 발급 시간, 만료 시간, 이메일, 권한 등등 )
자꾸햇갈리는데 Payload는 Claims들의 집합이고 JWT에서 Payload를 디코딩해서 속의 key-value 값자체가 Claims이라고한다....
헤더한테는 그런이름 안지어줬으면서ㅠ 왜 Payload 만 인코딩했을때랑 디코딩했을떄 이름이 다른건지 넘 헷갈렸는데
Payload는 JWT 구성요소의 역할 이름 이고 Claims은 Payload 안에 들어있는 실제 데이터들 ;;
걍 같은말인데 관점이 다르다고 보면댐.. 아무래도 안에 들어가는 값들이 JWT를 사용하는 목적의 핵심 내용 이기때문에 주장..Claims라고하고
헤더는 걍 JWT해석하려는 메타정보만 들어있어서 걍 Key-Value라고 하는듯..ㅠ 아오 헷갈려
Base64 인코딩된 문자열 (Payload 역할)
→ 디코딩됨 → JSON 형태의 Claims
- `sub` : subject (사용자 식별자)
- `iat` : issued at (발급 시간)
- `exp` : expiration (만료 시간)
- Payload는 Base64로 인코딩되므로 누구나 읽을 수 있음. (암호화된 건 아님!)
→ 대신 Signature로 변조 여부만 방지함
{
"sub": "user123", ← subject (사용자 ID 등)
"iat": 1719831000, ← issued at (발급 시각)
"exp": 1719834600, ← expiration (만료 시각)
"role": "ADMIN" ← 커스텀 claim도 가능
}
③ Signature
Header + Payload를 서버의 비밀키(secret key)로 서명한 결과
- 서명은 서버만 알고 있는 비밀키로 생성 → 이 값이 맞지 않으면 위조된 것으로 판단
HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
다시 Claims란?
- JWT 내부에 담긴 정보들 (예: 사용자 ID, 이메일, 발급시간, 만료시간 등)
- 일종의 Key-Value 구조의 데이터
{
"sub": "helloUser", ← subject
"iat": 1719825840, ← issued at
"exp": 1719829440 ← expiration
}
`getSubject()` → `"helloUser"` 반환됨
Base64?
- JWT는 Header, Payload, Signature 3부분으로 나뉘고 전부 Base64로 인코딩됨
- 사람이 볼 수 있는 문자열이지만 쉽게 조작되면 안 되니 서명을 추가로 함
- 예: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← 이게 Base64 인코딩된 Header
1. pom.xml 설정하기 (JWT 의존성 추가)
JWT 토큰 생성을 위해서는 `jjwt` 라이브러리를 프로젝트에 추가해야한다.
토큰생성, 파싱, 검증 같은 기능을 하는 도구다.
<!-- JWT core -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- 구현체 및 JSON 처리용 의존성 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2. JWT 토큰 발급 로직 구현 (JwtTokenProvider)
우선 비밀키생성 , 만료시간 을 정한 후
아래의 주요 기능을 구현했다
- 토큰 생성 (generateToken)
- 토큰 유효성 검증 (validateToken)
- 토큰에서 사용자 ID 추출 (getUserIdFromToken)
📍 비밀키 생성
비밀키 생성을 할 당시에 첨엔 아래와 같은 코드를 사용했었는데 비밀키가 넘 짧다고 안되가지고
private final String secretKey = Base64.getEncoder().encodeToString("my-jwt-secret-key".getBytes());
저 `io.jsonwebtoken.security` 패키지의 ` Keys.secretKeyFor(...)`를 활용해서 비밀키를 만들었다.
private final SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256); // 자동으로 강한 키 생성
📍 만료시간
만료시간은 한시간으로 하였고 밀리초단위라 저렇게 계산했다.
난 자바 시간계산하는게 첨엔 진짜 어렵고 짜증났었음..낵아왜 ㅋㅠ 넘 헷갈리지않나 걍 1h 이런식으로 하면 안되냔 말야..
private final long expiration = 1000 * 60 * 60; // 1시간
📍토큰 생성 (generateToken)
로그인 성공한 유저에게 토큰을 발급한다.
//토큰 발급하기
public String generateToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(secretKey)
.compact();
}
| 용어 | 설명 |
| `setSubject` | JWT의 본문(Claims)에 `sub`라는 항목으로 저장됨. 여기서는 사용자 ID |
| `setIssuedAt` | 토큰이 발급된 시간 (`iat`) |
| `setExpiration` | 토큰이 만료되는 시간 (`exp`) |
| `signWith` | 비밀 키를 이용해서 토큰이 변조되지 않도록 서명 |
| `compact()` | 모든 설정을 하나의 문자열(JWT) 로 압축해서 반환 |
첨에 아 도대체 페이로드랑 시그니처만만들고 헤더는 언제만드나했는데 `Jwts.builder()` 할때 자동으로 Header가 생성된다고한다.. 커스텀 헤더를 만들수도있긴하다 .
Header 자동으로 생성되는 이유는
`signWith(secretKey)` 를 하면 JJWT 라이브러리가 어떤 알고리즘을 썼는지 알고 있으니까 그 정보를 바탕으로 `alg` 값을 자동으로 넣어준다. `typ: JWT` 는 거의 표준값이라 이것도 자동 추가됨(호환성떄매 안바꾸기 추천)
Map<String, Object> headerMap = new HashMap<>();
headerMap.put("alg", "HS256");
headerMap.put("typ", "JWT");
headerMap.put("kid", "key-id-01"); // 예시로 키 식별자 추가
String token = Jwts.builder()
.setHeader(headerMap) // 직접 헤더 설정
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(secretKey)
.compact();
📍토큰 유효성 검증 (validateToken)
클라이언트가 보낸 토큰이 정상인지 확인한다(만료되었거나 위조되었는지 확인!)
//토큰 유효성 확인
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token); //파싱 or 검증
return true;
} catch (JwtException e) {
return false;
}
}
`parseClaimsJws(token)`
- 토큰을 해석하면서 서명 검증 + 만료 검증을 동시에 함
- 오류가 없으면 OK → true
- 오류(서명 불일치, 만료 등) 나면 예외 → `false`
📍토큰에서 사용자 ID 추출 (getUserIdFromToken)
토큰에 저장된 사용자 ID(Subject)를 꺼냄
//유저 토큰 가져오기
public String getUserIdFromToken(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getSubject(); // 로그인할 때 .setSubject(userId)로 넣은 값
}
전체코드
@Component // 스프링이 이 클래스를 빈으로 등록해줌. 의존성 주입 가능
public class JwtTokenProvider {
// 비밀 키 (자동으로 강한 키 생성/ 별도로 문자열로 바꾸지 않아도 됨)
private final SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 토큰 만료 시간 (1시간: 1000ms * 60s * 60m)
private final long expiration = 1000 * 60 * 60;
// [1] 토큰 발급
public String generateToken(String userId) {
return Jwts.builder()
.setSubject(userId) // 토큰에 저장할 주체 (여기선 사용자 ID)
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 만료 시간
.signWith(secretKey) // 비밀 키로 서명
.compact(); // 최종 JWT 문자열로 만듦
}
// [2] 토큰 유효성 확인
public boolean validateToken(String token) {
try {
// 토큰을 파싱해서 문제 없으면 true
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (JwtException e) {
// 서명 오류, 만료, 형식 오류 등 발생 시 false
return false;
}
}
// [3] 토큰에서 사용자 ID 추출
public String getUserIdFromToken(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getSubject(); // .setSubject(userId)로 넣은 값 꺼내기
}
}
3. 로그인 성공 시 JWT 토큰 발급 (UserServiceImpl)
아이디, 비밀번호가 유효할 경우 JWT 토큰 생성
비번 암호화해야겟다..ㅋ 파람으로 받질않나 암호화안하질않나 ㅎㅎ 수정해야지..ㅋ
@Override
public ApiResponse<?> jwtLoginUser(UserLoginDto requestDto) {
String reqId = requestDto.getUserId();
String reqPwd = requestDto.getUserPwd();
// 아이디 존재 확인
if (!userRepository.existsById(reqId)) {
return ApiResponse.error("아이디가 존재하지 않습니다.");
}
// DB에서 아이디로 유저 조회
UserVO userVO = userRepository.findByUserId(reqId);
// 비밀번호 일치 확인
if (!reqPwd.equals(userVO.getUserPwd())) {
return ApiResponse.error("비밀번호가 일치하지 않습니다.");
}
// 토큰 생성
String token = jwtTokenProvider.generateToken(userVO.getUserId());
// 로그인 결과 객체에 사용자 이름과 토큰 담기
LoginResultDto loginDto = new LoginResultDto(userVO.getUserNm(), token);
// ApiResponse형식으로 반환
return ApiResponse.success("로그인성공", loginDto);
}
4. ApiResponse에 토큰 포함 (Controller에서 Header에 추가)
생성된 JWT 토큰을 응답 HTTP 응답 Header에 담아서 클라이언트가 저장할 수 있도록 전달한다.
그러면 나중에 클라이언트가 이후 요청시 이 토큰을 들고와서 사용할 수 있다.
@PostMapping("/jwt/login")
public ResponseEntity<ApiResponse<?>> jwtLoginUser(@RequestBody UserLoginDto requestDto) {
ApiResponse<?> response = userServiceImpl.jwtLoginUser(requestDto);
// 로그인 실패 시 (토큰 없음)
if (!response.isSuccess()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
// 로그인 성공 시
LoginResultDto loginDto = (LoginResultDto) response.getData();
return ResponseEntity.ok()
// JWT를 응답 Header에 추가 (key: Authorization, value: Bearer <token>)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + loginDto.getToken())
// 클라이언트가 JS로 헤더를 읽을 수 있게 허용
.header("Access-Control-Expose-Headers", "Authorization")
// 실제 응답 본문
.body(ApiResponse.success("로그인성공", loginDto));
}
Access-Control-Expose-Headers 헤더를 같이 추가하면 클라이언트에서 Authorization 헤더 접근이 쉬워진다.
.header("Access-Control-Expose-Headers", "Authorization")
5. 토큰 인증 테스트 API (수후 수정삭제에 반영 + 쿠키로 바꿀꺼임)
테스트시에 header 에 자꾸 넣어줘야하는 번거로움 + 기타등등의 사유로 쿠키로 바꿔야겟음.. 고고.~ ㅜ
@GetMapping("/jwt/info")
public ResponseEntity<?> getUserInfo(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.replace("Bearer ", "");
if (jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserIdFromToken(token);
// ApiResponse 성공 포맷으로 반환
ApiResponse<String> response = ApiResponse.success("토큰 인증 성공", "안녕하세요, " + userId + "님");
return ResponseEntity.ok(response);
} else {
// ApiResponse 에러 포맷으로 반환
ApiResponse<?> errorResponse = ApiResponse.error(401, "토큰이 유효하지 않습니다.");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
}
테스트 결과
1. 로그인을 한다~

2. 발급받은 토큰을 header에 넣고 다시 테스트 화면을 부른다


'공부일기.. > Spring' 카테고리의 다른 글
| Redis 캐시1 - RedisCacheManager (@Cacheable) 사용하기 (3) | 2025.08.16 |
|---|---|
| [Spring Boot] 게시판 만들기③ | EC2에 배포하고 실행까지 따라하기 (0) | 2025.07.06 |
| [Spring Boot 게시판 ②] CRUD API , Postman 테스트(4/4) (0) | 2025.06.24 |
| [Spring Boot 게시판 ②] CRUD API 개발(3/4) (0) | 2025.06.24 |
| [Spring Boot 게시판 ②] CRUD API 구현 (2/4) (0) | 2025.06.23 |
