[Spring Boot 게시판 ] JWT 로그인 구현 하기

2025. 7. 1. 18:55·공부일기../Spring

 
원래는 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를 담아 토큰 생성
  • 로그인 성공 시 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 payload에 많은 정보를 담으면 토큰 크기가 커져 네트워크 부하를 유발할 수 있다.
로그아웃
JWT는 서버에서 상태를 관리하지 않기때문에 로그아웃 시 토큰을 무효화하는 작업이 꼭꼭 필요하다 . 

 

📍 사용이유

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
'공부일기../Spring' 카테고리의 다른 글
  • Redis 캐시1 - RedisCacheManager (@Cacheable) 사용하기
  • [Spring Boot] 게시판 만들기③ | EC2에 배포하고 실행까지 따라하기
  • [Spring Boot 게시판 ②] CRUD API , Postman 테스트(4/4)
  • [Spring Boot 게시판 ②] CRUD API 개발(3/4)
s0-0mzzang
s0-0mzzang
공부한것을 기록합니다...
  • s0-0mzzang
    승민이의..개발일기..🐰
    s0-0mzzang
  • 전체
    오늘
    어제
    • 전체~ (108)
      • 마음가짐..! (10)
      • 공부일기.. (76)
        • weekly-log (6)
        • Spring (19)
        • Java (18)
        • DataBase (10)
        • git (2)
        • JPA (6)
        • kafka (1)
        • Backend Architecture (3)
        • Troubleshooting (삽질..ㅋ) (2)
        • Cloud (1)
        • Docker (2)
        • 알고리즘 (1)
        • 리액트 (2)
        • Infra (3)
      • 하루일기.. (22)
        • 그림일기 (8)
        • 생각일기 (14)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    자바
    MySQL
    항해플러스
    다짐
    StringTokenizer
    ADC 환경
    spring boot
    BufferedReader
    SpringBoot
    swagger
    항해99
    spring
    JPA
    인프라 기초
    스프링부트
    ERD
    React
    Paging
    TDD
    리팩토링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
s0-0mzzang
[Spring Boot 게시판 ] JWT 로그인 구현 하기
상단으로

티스토리툴바