JWT(JSON Web Token)는 로그인 인증에 사용되는 토큰 방식이야.
핵심 포인트: 서버는 토큰을 저장하지 않는다.
일반적인 세션 방식은 서버가 로그인 상태를 저장하지만, JWT는 다르다.
서버는 Secret Key로 서명만 해서 클라이언트에게 토큰을 발급하고,
이후 요청마다 그 서명이 유효한지만 검증한다. DB 조회 없이.
그래서 JWT의 장점은 Stateless(무상태) — 서버가 상태를 저장하지 않아도 된다.
① 클라이언트 → 서버: email + password 전송
② 서버 → DB: 유저 조회
③ DB → 서버: 유저 정보 반환
④ 서버: 비밀번호 검증 + JWT 생성 (서명)
⑤ 서버 → 클라이언트: JWT 반환
⑥ 이후 API 요청 시: Authorization: Bearer {token} 헤더에 포함
⑦ 서버: 서명 검증 (DB 조회 없이!)
JWT는 3개 파트로 구성된다:
header.payload.signature
eyJhbGc... . eyJ1c2VySWQiOjF9 . SflKxwRJSMeKKF2QT4...
header — 알고리즘 정보 (HS256)payload — 담을 데이터 (userId, email, 만료시간)signature — 위 두 개를 Secret Key로 서명한 값서명은 "내가 만든 토큰이 맞다"는 증명이다.
누군가 payload를 { userId: 99 }로 바꾸면? Secret Key가 없으니 signature를 다시 못 만든다.
서버가 검증할 때 "이 서명 이상한데?" 하고 바로 거부한다.
"payload가 중간에 변조되지 않았다는 걸 Secret Key로 보증하는 것"
build.gradle에 JWT 라이브러리 추가:
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
application.yml에 Secret Key 설정:
jwt:
secret: mysecretkey12345678901234567890123456789012
expiration: 86400000 # 24시간 (ms)
JWT 생성, 검증, userId 추출 3가지 기능을 담당하는 유틸 클래스:
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long expiration;
// 토큰 생성
public String generateToken(Long userId, String email) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
// userId 추출
public Long getUserId(String token) {
String subject = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
return Long.parseLong(subject);
}
}
왜 try-catch로 검증하나?
토큰이 만료되거나 서명이 틀리면 Jwts가 예외를 던진다.
그걸 잡아서 false를 반환하는 방식으로 유효성을 검증한다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsMemberByEmail(String email);
Optional<Member> findByEmail(String email); // 로그인용 추가
}
@Override
public LoginResponseDTO login(LoginRequestDTO loginRequestDTO) {
// 1. 이메일로 유저 조회
Member member = memberRepository.findByEmail(loginRequestDTO.getEmail())
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 회원입니다."));
// 2. 비밀번호 확인
if (!passwordEncoder.matches(loginRequestDTO.getPassword(), member.getPassword())) {
throw new IllegalArgumentException("비밀번호가 틀렸습니다.");
}
// 3. JWT 발급
String token = jwtProvider.generateToken(member.getId(), member.getEmail());
return new LoginResponseDTO(member.getId(), token);
}
포인트 1: orElseThrow() 활용
Optional.isPresent() 체크보다 훨씬 깔끔하다. 값이 없으면 바로 예외를 던진다.
포인트 2: passwordEncoder.matches() 사용
비밀번호는 BCrypt로 암호화되어 저장되므로 평문과 직접 비교하면 안 된다.
matches(평문, 암호화된값)으로 비교해야 한다.
@Getter
@AllArgsConstructor
public class LoginResponseDTO {
private Long memberId;
private String token;
}
@PostMapping("/login")
public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginRequestDTO loginRequestDTO) {
LoginResponseDTO response = memberService.login(loginRequestDTO);
return ResponseEntity.ok(response);
}
로그인, 회원가입 URI는 토큰 검증 없이 통과시키도록 설정:
@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
private final JwtProvider jwtProvider;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
// 로그인, 회원가입은 토큰 검증 스킵
if (uri.startsWith("/member/login") || uri.startsWith("/member/signUp")) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(401);
return false;
}
String token = authHeader.substring(7);
if (!jwtProvider.validateToken(token)) {
response.setStatus(401);
return false;
}
Long memberId = jwtProvider.getUserId(token);
request.setAttribute("memberId", memberId);
return true;
}
}
POST http://localhost:8080/member/login
{
"email": "test@test.com",
"password": "1234"
}
응답:
{
"memberId": 1,
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ..."
}
개발 중 ddl-auto: create로 설정하면 서버 재시작 시 테이블이 드롭되고 데이터가 날아간다.
데이터를 유지하려면 update로 변경해야 한다.
spring:
jpa:
hibernate:
ddl-auto: update # create → update
| 옵션 | 동작 |
|---|---|
| create | 매번 테이블 드롭 후 재생성 (데이터 삭제) |
| update | 변경사항만 반영, 데이터 유지 |
| validate | 테이블 구조 검증만 (변경 없음) |
| none | 아무것도 안 함 |
passwordEncoder.matches()로 암호화 비밀번호 비교Optional.orElseThrow() 패턴ddl-auto: create vs update 차이다음 포스팅: 상품 API + 주문 API 구현