[RestAPI 개발기] 8. JWT
May 09, 2025
1️⃣ 해시
1. 해시 해주는 라이브러리
implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.4'
2. 해시 테스트
해당 비밀번호만 해시를 하게되면, 똑같은 비밀번호로 해시를 해본 사람은 해당 해시값을 보고 상대방의 비밀번호를 알 수 있다.
그래서 salt(난수)를 함께 붙여서 해시를 하면 상대방은 알아 볼 수 없다.
@Test
public void encode_test() {
// $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu
// $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC
String password = "1234";
String encPassword = BCrypt.hashpw(password, BCrypt.gensalt());
System.out.println(encPassword);
}
@Test
public void decode_test() {
// $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu
// $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC
String dbpassword = "$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC";
String password = "1234";
String encPassword = BCrypt.hashpw(password, BCrypt.gensalt());
if (encPassword.equals(password)) {
System.out.println("비밀번호가 같아요");
} else {
System.out.println("비밀번호가 달라요");
}
}
@Test
public void decodev2_test() {
// $2a$10$z0vF.yjNdfk13sbwrQFs7uM1o8WRysh8TjucDNqe1d8tjvacivBfu
// $2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC
String dbpassword = "$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC";
String password = "1234";
Boolean isSame = BCrypt.checkpw(password, dbpassword);
System.out.println(isSame);
}
3. 실제 프로젝트에 적용
User 회원가입
// RestAPI 규칙1 : insert 요청 시에 그 행을 DTO에 담아서 리턴한다.
@Transactional
public UserResponse.DTO 회원가입(UserRequest.JoinDTO reqDTO) {
try {
String encPassword = BCrypt.hashpw((reqDTO.getPassword()), BCrypt.gensalt());
reqDTO.setPassword(encPassword);
User userPS = userRepository.save(reqDTO.toEntity());
return new UserResponse.DTO(userPS);
} catch (Exception e) {
throw new Exception400("잘못된 요청입니다");
}
}


2️⃣ JWT
1. JWT 라이브러리
implementation group: 'com.auth0', name: 'java-jwt', version: '4.3.0'
2. JWT 테스트
JWT 생성
JWT를 본 프로젝트에 적용하기 전에 테스트 코드로 미리 실행해보기.
미리 회원가입이 되어있는 User를 빌더로 꺼내옴. 이때 패스워드는 해시값이 DB에 저장되어 있기 때문에 해시값을 넣어야한다.
JWT를 생성 할 때에는 3가지가 적혀있어야한다.
- header : 암호화 방식
- PayLoad : 암호화 할 내용 (필수 사항으로는 만료 시간이 있다.)
- sign : 전자서명의 값
header
에서는 암호화 방식을 적어야한다. 해당 코드에서는 .sign(Algorithm.HMAC256);
방법을 사용하였다.HMAC256
은 단방향 (해시) 방식으로 복호화를 할 수 없다.PayLoad
에 해당 하는 부분은 .withSubject
.withExpiresAt
.withClaim
.withClaim
이다.
subject
는 보통 프로젝트명을 적는다. ExpiresAt
은 만료시간을 적는다. 해당 코드에서는 1시간으로 설정하였다. Claim
은 유저의 정보 등을 적는다. 단 민감한 정보, 예시로는 비밀번호 등은 적으면 안된다. 현실에서 종이에 정보를 적고 다니는 것과 같기 때문에 민감한 정보를 적으면 모두가 볼 수 있다.sign
은 header
의 값과 PayLoad
의 값을 가지고 Base64로 변환한 값을 저장한다. 이후 나중에 요청이 또 들어올 경우에는 header
와 PayLoad
값을 가지고 Base64로 변환 후 sign에 값과 같다면 인증완료 이다.하지만 이렇게 2개의 값만 가지고 인증을 하게 되면 누군가는 값을 변조해서
sign
에 덮어씌워도 인증이 된다. 그런 것을 막기 위해 추가로 인증을 하는 사람만 알 수 있는 값을 추가하여 총 3개의 값을 Base64로 변환하여 sign에 값과 같으면 인증완료가 되게 해야한다. 3번째 값을 우리는 Secret
이라고 한다. 해당 설정을 코드에서는 암호화 방식 다음 (””)하여서 적었다.PayLoad
의 값을 변조하고 와도 Secret
을 알고 있는 것이 아니면 인증이 불가능하다

// 토큰 생성
@Test
public void create_test() {
User user = User.builder()
.id(1)
.username("ssar")
.password("$2a$10$D9DExWw4OHqzezgmi7EFtONtc8g58C8dwlDQQQT9AlYuwhylu3tyC")
.email("ssar@nate.com")
.createdAt(Timestamp.valueOf(LocalDateTime.now()))
.build();
String jwt = JWT.create()
.withSubject("blogv3") // with 4가지는 payload에 들어감
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1초 * 60 * 60
.withClaim("id", user.getId())
.withClaim("username", user.getUsername())
.sign(Algorithm.HMAC256("metacoding")); // header : HMAC256 (단방향 해시)
// 198 156 236 87 42 53 186 254 56 151 169 7 107 178 5 197 147 172 56 100 145 97 133 14 17 46 135 193 73 199 201 144
// xpzsVyo1uv44l6kHa7IFxZOsOGSRYYUOES6HwUnHyZA
System.out.println(jwt);
}
해당 코드를 실행 할 경우 아래와 같은 로그가 콘솔에 나오는데 이것이 JWT이다.
JWT 홈페이지로 가서 인코딩을 해볼 경우 우리가 적은 값이 그대로 나오는 것을 볼 수 있다.
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJibG9ndjMiLCJpZCI6MSwiZXhwIjoxNzQ2NzcyNjA5LCJ1c2VybmFtZSI6InNzYXIifQ.4IPhr58Nh9XkZ9HoGtD2FL2oZurn_wrjO8kU90fRM2U

JWT 검증하기
JWT 라이브러리에서 제공해주는 함수들을 이용하면 검증도 할 수 있다.
위에서 만든 JWT를 변수에 저장하여 디코딩을 하였고, 함수들을 이용하여 JWT 내부에 어떤 데이터가 있는지도 확인이 가능하다. 그것을 마지막에 builder를 사용하여 user에게 다시 넘겨 주었다.
// 토큰 검증
@Test
public void verify_test() {
// 2025.05.09.11:50분 까지 유효
String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJibG9ndjMiLCJpZCI6MSwiZXhwIjoxNzQ2NzU4OTk4LCJ1c2VybmFtZSI6InNzYXIifQ.Ifjed8Sv0VG6GU3LG1pEstuDR4Rvcw_y5f5YSzl7Xlg";
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256("metacoding")).build().verify(jwt);
// 토큰 내에 데이터 확인
Integer id = decodedJWT.getClaim("id").asInt();
String username = decodedJWT.getClaim("username").asString();
System.out.println(id);
System.out.println(username);
User user = User.builder()
.id(id)
.username(username)
.build();
}

- JWT가 달라서 검증이 되지 않은 모습

- 만료 시간이 다 되어서 검증이 되지 않는 모습

3. 실제 프로젝트에 적용
1. JwtUtil 만들기
테스트 코드를 생성과 검증이 잘 되는 것을 확인 하였으니,
Util
파일에 JwtUtil
이라는 파일로 분리하여 필요한 곳에서 꺼내서 쓰겠다.public class JwtUtil {
public static String create(User user) {
String jwt = JWT.create()
.withSubject("blog")
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60))
.withClaim("id", user.getId())
.withClaim("username", user.getUsername())
.sign(Algorithm.HMAC512("metacoding"));
return jwt;
}
public static User verify(String jwt) {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512("metacoding")).build().verify(jwt);
int id = decodedJWT.getClaim("id").asInt();
String username = decodedJWT.getClaim("username").asString();
return User.builder()
.id(id)
.username(username)
.build();
}
}
2. Service
User가 로그인에 성공을 하게 되면 Token을 생성하여서 DTO에 담아서 Controller에게 넘겨준다
public UserResponse.TokenDTO 로그인(UserRequest.LoginDTO loginDTO) {
User userPS = userRepository.findByUsername(loginDTO.getUsername())
.orElseThrow(() -> new ExceptionApi401("유저네임 혹은 비밀번호가 틀렸습니다"));
Boolean isSame = BCrypt.checkpw(loginDTO.getPassword(), userPS.getPassword());
if (!isSame) throw new ExceptionApi401("유저네임 혹은 비밀번호가 틀렸습니다");
// 토큰 생성
String ascssToken = JwtUtil.create(userPS);
return UserResponse.TokenDTO.builder().ascssToken(ascssToken).build();
}
- 실제 DTO는 총 2개의 데이터를 담을 수 있지만, refreshToken은 잠시 사용하지 않겠다.
@Data
public static class TokenDTO {
private String ascssToken;
private String refreshToken;
@Builder
public TokenDTO(String ascssToken, String refreshToken) {
this.ascssToken = ascssToken;
this.refreshToken = refreshToken;
}
}
- Controller에서 DTO를 받았기 때문에 TokenDTO 타입으로 받아줬다.
@PostMapping("/login") // login은 민간함 정보가 들어가야하기때문에 Get 말고 Post
public @ResponseBody Resp<?> login(@Valid @RequestBody UserRequest.LoginDTO loginDTO, Errors errors, HttpServletResponse response) {
UserResponse.TokenDTO respDTO = userService.로그인(loginDTO);
return Resp.ok(respDTO);
}
3. Filter에 토큰 인증 구현
토큰을 인증 할 때에는 interceptor와 Filter 2가지 중 어느 곳에서든 할 수 있다. 하지만 interceptor로 처리를 하게 되면 DS가 실행이 되기 때문에 컴퓨터의 속도가 늦쳐질 수 있다. 어차피 인증을 해야하는 거라면 가능한 빨리 맨 앞에서 인증을 해주는 것이 효율적임으로 Filter에서 인증을 하였다.
AuthorizationFilter
토큰이 없을 때와, 토큰의 기간이 만료되었을 때, 토큰의 검증에 실패 했을 때를 각각 예외처리 하였다. 토큰을 확인 할 때에 프로토콜이 하나있다. 토큰의 값 앞에 Bearer 을 붙이는 것이다.
예시로 Bearer [토큰값] 을 적으면 된다.
public class AuthorizationFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
String accessToken = request.getHeader("Authorization");
try {
if (accessToken == null || accessToken.isBlank()) throw new RuntimeException("토큰을 전달해주세요");
if (!accessToken.startsWith("Bearer ")) throw new RuntimeException("Bearer 프로토콜 지켜주세요");
accessToken = accessToken.replace("Bearer ", "");
User user = JwtUtil.verify(accessToken);
// 토큰을 다시 검증하기 귀찮아서, 임시로 세션에 넣어둠
HttpSession session = request.getSession();
session.setAttribute("sessionUser", user);
chain.doFilter(request, response);
} catch (TokenExpiredException e1) {
e1.printStackTrace();
exResponse(response, "토큰이 만료되었습니다");
} catch (JWTDecodeException | SignatureVerificationException e2) {
e2.printStackTrace();
exResponse(response, "토큰 검증에 실패했어요");
} catch (RuntimeException e3) {
e3.printStackTrace();
exResponse(response, e3.getMessage());
}
}
private void exResponse(HttpServletResponse response, String msg) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.setStatus(401);
PrintWriter out = response.getWriter();
Resp<?> resp = Resp.fail(401, msg);
String responseBody = new ObjectMapper().writeValueAsString(resp);
out.println(responseBody);
}
}
FilterConfig
기본적으로 토큰을 검증하는 필터는 URL이
/s
로 시작하기 때문에 “/s/*”
을 사용하였다.
로그인 후에 토큰을 검증하기 때문에 순서도 2번째로 만들었다.@RequiredArgsConstructor
@Configuration
public class FilterConfig {
private final UserRepository userRepository;
@Bean
public FilterRegistrationBean<AuthorizationFilter> authorizationFilter() {
FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new AuthorizationFilter());
registrationBean.addUrlPatterns("/s/*"); // 모든 요청에 적용
registrationBean.setOrder(2); // 필터 순서 설정
return registrationBean;
}
@Bean
public FilterRegistrationBean<LogFilter> loggingFilter() {
FilterRegistrationBean<LogFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new LogFilter(userRepository));
registrationBean.addUrlPatterns("/*"); // 모든 요청에 적용
registrationBean.setOrder(1); // 필터 순서 설정
return registrationBean;
}
}
- 프로젝트를 실행하여 POSTMAN을 이용하여 정상 작동하는지 확인



Share article