JWT 적용기

JeongYong Park·2023년 7월 27일
2


이번 이슈 트래커 프로젝트를 진행하며 JWT를 통한 로그인을 구현하게 되었습니다. 이전에는 세션을 통해 로그인을 구현했었는데 둘은 어떤 차이가 있고 Spring과 JWT를 통해 로그인을 구현한 과정을 소개하겠습니다.

Stateless한 HTTP

먼저 HTTP의 특성을 소개하려고 합니다. HTTP는 무상태 프로토콜로 서버가 클라이언트의 상태를 보관하지 않는다는 특성이 있습니다. HTTP는 서버가 클라이언트의 요청을 처리하면 연결을 끊어 클라이언트에 대한 이전 정보를 가지고 있지 않게 됩니다.

이러한 방식은 서버의 확장성(Scale-out)을 높일 수 있고 불필요한 자원의 낭비를 줄일 수 있다는 장점이 있습니다.

상태를 계속 유지한다면 서버마다 클라이언트의 정보를 가지고 있어야겠죠?

그런데 우리는 로그인을 한 번 하고 나면 로그인 상태를 유지할 수 있습니다. 어떻게 이를 가능케할까요? 바로 쿠키와 세션 기술을 이용해 이를 가능케합니다.

세션에 대해 먼저 설명하기 전에 쿠키에 대해 알아보겠습니다.

MDN에는 쿠키가 다음과 같이 정의되어 있습니다.

HTTP 쿠키(웹 쿠키, 브라우저 쿠키)는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송합니다. 쿠키는 두 요청이 동일한 브라우저에서 들어왔는지 아닌지를 판단할 때 주로 사용합니다. 이를 이용하면 사용자의 로그인 상태를 유지할 수 있습니다. 상태가 없는(stateless) HTTP 프로토콜에서 상태 정보를 기억시켜주기 때문입니다.

클라이언트는 서버에 요청을 보내게 되면 서버는 Set-Cookie 헤더에 쿠키를 담아 전달해 줍니다. 그러면 클라이언트는 서버에서 받은 쿠키를 저장하고 이후 HTTP 요청시 해당 쿠키를 담아 전달하게 됩니다.

주의할 점은 요청시 데이터를 쿠키에 그대로 담아 보내기 때문에 쿠키가 탈취당한다면 그대로 정보가 노출되기 떄문에 민감한 정보는 담지 않는 것이 좋습니다.

Session

세션은 쿠키와 다르게 민감한 정보들은 서버에 저장해두고 서버는 SESSION_ID를 Set-Cookie 헤더에 담아 보내게 됩니다.

HTTP/1.1 200 OK
Set-Cookie: weohfeq2390ghehg42q

이렇게 되면 클라이언트는 SESSION_ID를 가지고 있게 되고 이후 요청마다 SESSION_ID를 Cookie헤더에 담아 서버에 보내게 됩니다.

이러한 세션방식의 장단점은 다음과 같습니다.

  • 장점
    • SESSIONID 방식을 사용하고 있기 때문에 해당 ID에 매칭된 회원정보를 바로 확인할 수 있어 편리하다.
    • 쿠키가 탈취당하더라도 사용자의 정보가 아닌 무의미한 정보가 들어가 있기 때문에 쿠키보다 안전하다.
  • 단점
    • SESSIONID를 중간에 탈취해 클라이언트인척 위장할 수 있다는 한계점이 존재한다.
    • 서버를 증설하게 되면 각 서버마다 존재하는 세션정보가 일치하지 않아 Scale-out에 불리하다.
      • 이 경우 공통의 세션 DB를 만들어 관리하는 방법이 있다.

session 을 이용할 때 scale-out시 대처
1. sticky session
2. session clustering
3. 외부 session 저장소 이용 (ex. redis)

JWT

JWT(Json Web Token)는 서명된 토큰입니다. 먼저 JWT가 어떻게 생겼는지 알아보겠습니다.

JWT의 구성요소는 아래 3가지와 같은데, 점(.)으로 구분되어 있습니다.

  • Header
  • Payload
  • Signature

Header는 토큰 타입과 토큰 생성에 어떤 알고리즘이 사용되었는지 알려줍니다.

그림에서 보면 HS256 알고리즘을 사용했고 JWT 타입인 것을 알 수 있습니다.

HS256 대칭키 방식의 알고리즘 말고 RS256 비대칭키 방식의 알고리즘을 사용하는 방식도 있습니다.

Payload

Payload는 토큰에 담을 정보를 저장하고 있습니다. Key-Value 한 쌍의 정보를 Claim 이라고 합니다.

jwt.io 문서에 따르면 Claim은 registered, public, private 이렇게 3종류가 있습니다.

  • registered - 필수는 아니지만 미리 정의된 클레임의 집합이다.
    • iss(issuer) 토큰 발급자
    • exp(Expiration Time) 토큰 만료 시간
    • iat(Issued At) 토큰 발급 시간
    • aud(Audience) 토큰 대상자
    • sub(subject) 토큰 인증 주체

위와같이 표준 스펙으로 정의된 Claim 스펙이 존재합니다. 위에서 말했듯이 필수는 아니기 때문에 상황에 따라 적절히 사용하면됩니다.

  • public
    • JWT를 사용하는 사용자들이 마음대로 정의할 수 있다. 하지만 충돌 방지를 위해서 이곳(IANA Json Web Token Registry)에 등록하거나 충돌방지 네임스페이스를 포함하는 URI로 정의해야 한다.
  • private
    • 사용에 동의한 regitered 클레임이나 public 클레임이 아닌 당사자들간에 정보를 공유하기 위해 사용되는 클레임이다.

Signature

Signature는 헤더와 페이로드가 비밀키로 서명되어 저장됩니다.

언제 JWT를 사용한게 좋을까?

웹에서 쿠키(cookie)와 세션(session)을 이용한 사용자 인증을 구현하는 방식과 비교해 봤을 때 확장성에 있어 가장 큰 차이를 보입니다. JWT는 이미 사용자의 정보가 저장되어 있고 서버는 이를 검증만 해주면 되기 때문에 세션과 다르게 따로 저장소를 둘 필요가 없습니다.

그렇기 때문에 JWT를 사용할 때는 사용자가 늘어나도 인증을 위한 저장소를 둘 필요가 없으니 인프라 비용을 절감할 수 있습니다.

JWT, 항상 좋을까?

위를 보면 항상 JWT가 좋아보일 수 있습니다. 하지만 JWT는 몇 가지 한계점이 존재합니다.

  1. 한 번 발급한 토큰에 대한 제어권이 없다.
    • JWT는 발급된 후에는 취소할 수 없으며 유효 기간이 지나기 전까지 계속 유효합니다. 따라서 토큰을 강제로 만료시키거나 취소해야 할 때, 서버 측에서 추가적인 관리 및 로직이 필요합니다
  2. 페이로드 크기 제한
    • JWT는 인코딩된 문자열이기 때문에 많은 정보를 담는 경우 페이로드의 크기가 커지며 이는 네트워크의 부하를 증가시킬 수 있습니다.
  3. 데이터 변경 감지
    • 토큰에는 발급된 후에 데이터가 변경되었는지를 확인하는 기능이 없습니다. 따라서 토큰을 갱신하지 않고는 변경된 데이터를 알 수 없습니다.

토큰에 대한 제어권이 없기 때문에 규모가 큰 서비스에서는 JWT를 사용하기에는 부족한 느낌이 있습니다. 예를 들어 여러 장치에서 로그인을 하는 것을 막고 싶은 경우 JWT를 사용하기보다는 세션을 이용해야하기 때문입니다.

JWT 적용기

저희 프로젝트는 JWT를 사용하기로 결정했는데요, 규모가 큰 서비스도 아니고 여러 장비를 고려하지 않고 인프라 비용도 절감하기 위해 JWT를 선택했습니다. 또한 빠른 로그인 구현에 초점을 맞추었기 때문에 refreshToken의 발급은 고려하지 않았습니다.

이제 Spring 환경에서 JWT를 적용해보겠습니다.

프로젝트의 기술 정보는 다음과 같습니다.

  • Java11
  • Spring Boot 2.7.14

의존성 추가

먼저 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'

JWT 프로퍼티 설정

@Getter
@ConfigurationProperties("jwt")
public class JwtProperties {

	private final String secretKey;
	private final long expirationMilliseconds;

	@ConstructorBinding
	public JwtProperties(String secretKey, long expirationMilliseconds) {
		this.secretKey = secretKey;
		this.expirationMilliseconds = expirationMilliseconds;
	}
}

현재 프로젝트에서 비밀키는 코드상에 드러나면 안되기 때문에 application-jwt.yml 파일로 비밀키와 만료시간을 관리하고 있습니다.
yml 파일에서 정보를 읽어오기 위해 @ConfigurationProperties("jwt")를 통해 설정정보를 읽어옵니다. 이후 JwtProperties 클래스를 읽어 Jwt 빈을 등록해 줍니다.

@EnableConfigurationProperties(JwtProperties.class)
public class JwtConfig {

	private final JwtProperties properties;

	public JwtConfig(JwtProperties properties) {
		this.properties = properties;
	}

	@Bean
	public Jwt jwtProperties() {
		return new Jwt(properties.getSecretKey(), properties.getExpirationMilliseconds());
	}
}

Jwt 발급로직

저희는 로그인에 성공한 사용자에 대해 토큰을 발급해주는 로직입니다. 그렇기 때문에 JWT를 발급해주는 로직을 작성하겠습니다.

@Component
public class JwtProvider {

	private final SecretKey secretKey;
	private final long expirationMilliseconds;

	public JwtProvider(Jwt jwt) {
		this.secretKey = Keys.hmacShaKeyFor(jwt.getSecretKey().getBytes(StandardCharsets.UTF_8));
		this.expirationMilliseconds = jwt.getExpirationMilliseconds();
	}

	public String createToken(String payload) {
		Date now = new Date();
		return Jwts.builder()
			.signWith(secretKey, SignatureAlgorithm.HS256)
			.setIssuedAt(now)
			.setExpiration(new Date(now.getTime() + expirationMilliseconds))
			.setClaims(Map.of("userId", payload))
			.compact();
	}
}

createToken(String payload) 메서드가 토큰을 발급하는 로직입니다.

  • .signWith(secretKey, SignatureAlgorithm.HS256)
    • 먼저 JWT를 HS256알고리즘을 통해 secretKey로 서명합니다.
  • setIssuedAt(now)
    • JWT가 언제 발급되었는지 설정합니다.
  • setExpiration(new Date(now.getTime() + expirationMilliseconds))
    • JWT의 만료시간을 설정합니다.
  • setClaims(Map.of("userId", payload))
    • JWT의 페이로드의 클레임을 설정합니다.
    • User의 id를 페이로드에 담았습니다.
    • 이 클레임을 sub로 설정할 수도 있을 것 같습니다.

이제 로그인에 성공하면 다음과 같이 토큰을 발급해주는 것을 확인할 수 있습니다.

{
    "tokenType": "Bearer",
    "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIyIn0.bvKP6d_hTx2stQj0k4ROa7LjDaD-ddncZjZ1jmd1VfY"
}

토큰 검증

인증되지 않은 사용자는 Spring Context까지 요청이 올 필요가 없다고 생각했기 때문에 토큰의 검증은 Filter단에서 진행하기로 결정했습니다.

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

	private static final String AUTHORIZATION = "Authorization";
	private static final String BEARER = "bearer";
	private static final int TOKEN_INDEX = 1;

	private static final AntPathMatcher pathMatcher = new AntPathMatcher();
	private static final List<String> excludeUrlPatterns = List.of("/api/auth/**");

	private final JwtProvider jwtProvider;

	@Override
	protected boolean shouldNotFilter(HttpServletRequest request) {
		return excludeUrlPatterns.stream()
			.anyMatch(pattern -> pathMatcher.match(pattern, request.getServletPath()));
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		extractJwt(request).ifPresentOrElse(jwtProvider::validateToken, () -> {
			throw new ApplicationException(ErrorCode.EMPTY_JWT);
		});

		filterChain.doFilter(request, response);
	}

	private Optional<String> extractJwt(HttpServletRequest request) {
		String header = request.getHeader(AUTHORIZATION);

		if (!StringUtils.hasText(header)) {
			return Optional.empty();
		}

		if (header.toLowerCase().startsWith(BEARER)) {
			return Optional.of(header.split(" ")[TOKEN_INDEX]);
		}
		return Optional.empty();
	}
}
  • OncePerRequestFilter를 상속받는 JwtFilter를 생성합니다.
    • 인증/인가에 대해서는 한 번의 검증만 필요하기 때문에 OncePerRequestFilter를 상속받았습니다.
  • shouldNotFilter를 오버라이딩해서 회원가입/로그인에 대해서는 인증/인가 로직을 수행하지 않게합니다.
  • doFilterInternal메서드로 JWT를 검증합니다.
    • extractJwt메서드를 통해 Authorization 헤더로 넘어온 토큰을 추출합니다.
    • 이후 토큰이 유효한지 검증합니다.

토큰의 검증로직은 다음과 같습니다.

// JwtProvider
    public void validateToken(final String token) {
		try {
			Jwts.parserBuilder()
				.setSigningKey(secretKey)
				.build()
				.parseClaimsJws(token);
		} catch (ExpiredJwtException e) {
			throw new ApplicationException(ErrorCode.EXPIRED_JWT);
		} catch (JwtException e) {
			throw new ApplicationException(ErrorCode.INVALID_JWT);
		}
	}

빈 등록

이제 JwtFilter를 빈으로 등록해줍니다.

    @Bean
	public FilterRegistrationBean<JwtFilter> jwtFilter() {
		FilterRegistrationBean<JwtFilter> jwtFilter = new FilterRegistrationBean<>();
		jwtFilter.setFilter(new JwtFilter(jwtProvider));
		jwtFilter.addUrlPatterns("/api/*");
		return jwtFilter;
	}

(+추가) Preflight Request

문제 상황

  • 클라이언트는 로그인, 회원가입을 제외한 모든 요청에 대해 Authorizatoin 헤더에 JWT를 넣어 요청
  • API 서버는 인증 처리를 위해 Client 요청에 대해 Header의 Authorization 헤더의 정보를 검증
    • 유효하지 않다면 응답 코드를 401로 설정하여 응답

이 상황에서 요청을 날렸을 때 만나는 문제는 다음과 같았습니다.

Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. The response had HTTP status code 401. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

CORS 설정을 해주었지만 CORS 키워드가 나와 당황했습니다. 그런데 CORS 문제라면 405 응답이 나타나야 하는데 401응답을 준다는 것이 이상했습니다. 이는 Preflight 처리 중 발생한 문제였습니다.

해결

Preflight Request일 경우 JWT 검증 로직을 수행하지 않도록 했습니다.

		if (CorsUtils.isPreFlightRequest(request)) {
			filterChain.doFilter(request, response);
			return;
		}
		String token = extractJwt(request).orElseThrow(() -> new ApplicationException(ErrorCode.EMPTY_JWT));
		jwtProvider.validateToken(token);
		authenticationContext.setPrincipal(jwtProvider.extractUserId(token));

		filterChain.doFilter(request, response);

결론

  • 로그인을 구현할 때 Session 혹은 JWT 방식을 선택할 수 있습니다.
  • Session은 확장을 고려해야 하고 JWT는 규모가 큰 서비스에서 한계점이 존재합니다.
  • Spring에서 JWT를 적용해보았습니다.
    • 로그인에 성공하면 토큰을 발급합니다.
    • Filter단에서 JWT를 검증합니다.

참고자료

https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies

https://jwt.io/introduction

profile
다음 단계를 고민하려고 노력하는 사람입니다

4개의 댓글

comment-user-thumbnail
2023년 7월 27일

이런 유용한 정보를 나눠주셔서 감사합니다.

1개의 답글
comment-user-thumbnail
2023년 7월 28일

감사합니다! 구현 하는데 도움이 많이 될 것 같아요🙇🏻‍♀️

답글 달기
comment-user-thumbnail
2023년 7월 28일

글 천천히 읽어 보니까 이해 완전 잘 돼요 ㅎㅎㅎ
프로젝트에 그대로 써먹으면 되겠다 👀

답글 달기