기존의 인증이 성공된 사용자 정보를 관리하는 방법은 두 가지가 있었는데 바로 쿠키와 세션이다. 각 인증 방법의 단점은 다음과 같다.
쿠키
세션
따라서 세션과 쿠키를 이용하여 인증 정보를 관리하는 건 적절하지 못한 설계이다. 때문에 최근에는 클라이언트에 서버에 사용자 요청을 보냈을 때 유일 값인 토큰을 발급하여 인증이 필요한 경우 클라이언트에서 요청을 보낼 때 요청 헤더에 토큰을 삽입하여 보낸다. 물론 이 토큰 인증 방식의 단점도 존재한다는 것을 유념해야 한다.
단점
탈취에 대한 부분은 JWT에서 자체 만료시간을 두어 토큰이 탈취당하면 파싱 과정에서 유효하지 않은 토큰임을 판별할 수 있게 했다.
JWT는 Json Web Token의 약자이다. 인증에 필요한 정보를 암호화 시킨 JSON 토큰으로 Base64 URL-Safe-Encode를 통해 인코딩하여 직렬화한다. 또한 토큰 내부에 위/변주 방지를 위한 개인키를 통한 전자 서명을 포함한다.

토큰 구조를 살펴보자.
| 구성 요소 | 설명 |
|---|---|
| 헤더 | JWT에서 사용할 토큰의 타입과 암호화 알고리즘 정보로 구성하여 키-값의 형태로 구성한다. |
| 페이로드 | 서버로 보낼 사용자 권한 정보와 데이터로 구성한다. (키-값) 토큰에 담을 클레임 정보를 포함하는데, 이때 클레임이란 페이로드에 담는 정보의 한 조각(키-값)이다. 여러 개의 클레임 정보를 담을 수 있지만 암호화가 되어 있지 않기 때문에 민감 정보를 다루는 건 조심해야 한다. |
| 서명 | 서버의 개인 키를 포함하여 암호화하여 토큰의 유효성을 검증하기 위한 문자열을 담는다. |
일반적으로 JWT를 사용할 때에는 엑세스 토큰과 리프래시 토큰을 두 개 발급한다. 여기서는 엑세스 토큰의 페이로드에 사용자 번호를 담을 것이고, 리프래시 토큰은 엑세스 토큰이 만료되었을 때 클라이언트에서 재발급 받는 용도로 사용할 것이다. 클라이언트에서는 서버로부터 메시지 바디를 통해 응답을 받아서 엑세스 토큰을 로컬 또는 세션 스토리지에 저장하고, 리프레시 토큰은 서버에서 응답을 할 때 쿠키로 전달한다.
OAuth 인증이 성공하면, 이제 사용자 정보가 아닌 JWT를 반환하도록 할 것이다.
// JJWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.6'
JWT를 다루기 위해서 라이브러리 설정을 해야 한다. JWT 관련 라이브러리 중에 jjwt가 범용성이 좋다고 하여 선택하였다.
우선 JWT 공식 홈페이지로 가서 서명을 발급받아야 한다.

여기서 서명 부분을 사용하면 된다. 이 값을 설정 파일에 다음과 같이 설정할 것이다.
# application-auth.yml
jwt:
secret: nENNz0tGIfhz2ehg4XBoe30K4nLg2vPs8JQjfKde_m8
refresh-token-expiration-time: 604800000
access-token-expiration-time: 3600000
이제 JWT를 발급하고 파싱하는 클래스를 설계해야 한다. 하나하나 살펴보자.
@Component
public class JwtUtil {
// 서명 설정을 위한 키
private final SecretKey secretKey;
// 엑세스 토큰 만료 시간
private final long accessTokenExpirationTIme;
// 리프레시 토큰 만료 시간
private final long refreshTokenExpirationTIme;
// 레디스 대용
private final TempStore tempStore;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.access-token-expiration-time}") long accessTokenExpirationTIme,
@Value("${jwt.refresh-token-expiration-time}") long refreshTokenExpirationTime,
TempStore tempStore
) {
this.secretKey = Keys.hmacShaKeyFor((byte[]) Decoders.BASE64.decode(secretKey));
this.accessTokenExpirationTIme = accessTokenExpirationTIme;
this.refreshTokenExpirationTIme = refreshTokenExpirationTime;
this.tempStore = tempStore;
}
}
참고로 이 유틸 클래스를 사용하는 곳은 모두 스프링 빈으로 등록이 되어 있기 때문에 굳이 정적 메소드로 사용할 이유가 없다고 생각하여서 @Component로 의존성 관리를 하였다.
그리고 TempStore는 레디스에 리프레시 토큰을 저장하기 전에 사용할 임시 저장소다. 원래 간편하게 세션으로 관리하려고 했는데, 토큰 발급 과정에서 JSESSION이 한 번 초기화되어 제대로 조회가 되지 않아 Map 자료구조를 이용해서 임시 저장소를 만들었다.
@Component
public class TempStore {
private static final Map<String, Object> userStore = new HashMap<>();
public void store(String token, Object userId) {
userStore.put(token, userId);
}
public Object get(String token) {
return userStore.get(token);
}
public void remove(String token) {
userStore.remove(token);
}
}
Jwts가 빌더를 잘 만들어놔서 어떤 것을 사용해야 토큰을 발급할 수 있는지만 참고하면 발급 자체는 쉽다.
public UserToken generateToken(String id) {
String accessToken = createToken(id, accessTokenExpirationTIme);
String refreshToken = createToken("REFRESH_TOKEN", refreshTokenExpirationTIme);
tempStore.store(refreshToken, id);
return new UserToken(refreshToken, accessToken);
}
private String createToken(String id, long expirationTime) {
// 토큰 발행 시간
Date issuedData = new Date();
// 토큰 만료 시간
Date expirationData = new Date(issuedData.getTime() + expirationTime);
// 주제(사용자 정보), 발급 시간, 만료 시간, 서명
return Jwts.builder()
.claim("id", id)
.issuedAt(issuedData)
.expiration(expirationData)
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
public record UserToken(
String refreshToken,
String accessToken
) {
}
claim() 메서드를 사용해 토큰 페이로드에 id 값을 담았다. 하지만 JWT는 JSON 포맷을 따르기 때문에 Java의 구체적인 숫자 타입(Long 등)이 완벽하게 보존되지 않고 일반적인 숫자형(Number)으로 직렬화된다. 이 때문에 엑세스 토큰을 파싱하여 id를 꺼낼 때는 라이브러리가 임의로 해석한 타입(Integer 등)과 충돌하지 않도록 명시적인 타입 변환이 필요하다.
만약 이러한 타입 이슈 없이 단순히 식별자 값만 저장하고 싶다면, 모든 값을 문자열로 취급하는 표준 클레임인 subject() 메서드를 활용하는 방법도 있다. 한편, 발급받은 리프레시 토큰은 검증을 위해 리프레시 토큰 - 사용자 번호 구조로 매핑하여 저장소에 저장하였다.
토큰을 검증하는 것 또한 Jwts가 다 해준다.
private Jws<Claims> parseToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
} catch (UnsupportedJwtException | IllegalArgumentException e) {
throw new InvalidJwtException(NOT_SUPPORTED_TOKEN_FORMAT);
}
}
// 파싱 도중 예외가 발생하면 만료된 토큰으로 취급
private void validateAccessToken(String accessToken) {
try {
parseToken(accessToken);
} catch (JwtException e) {
throw new TokenExpiredException(ACCESS_TOKEN_EXPIRED);
}
}
private void validateRefreshToken(String refreshToken) {
try {
parseToken(refreshToken);
} catch (JwtException e) {
throw new TokenExpiredException(REFRESH_TOKEN_EXPIRED);
}
}
private void validateTokens(String accessToken, String refreshToken) {
validateAccessToken(accessToken);
validateRefreshToken(refreshToken);
}
Jwts의 parser()는 파싱 도중 발생하는 다양한 JWT 관련 예외를 던져주며, 심지어 토큰 만료 시간 마저도 감지한다. 따라서 이 예외를 감지하여 적절한 서버 예외 처리를 해주면 된다. parseToken()에서 반환 타입은 Jws<Claims>이다. 이거는 JWT의 해석의 결과를 담고 있는 객체라고 생각하면 된다. 따라서 이 객체 인스턴스에 접근하면 헤더, 페이로드, 시그니처를 가져올 수 있다.
엑세스 토큰으로부터 사용자 번호를 가져오기 전에, 클라이언트 입장에서 엑세스 토큰을 헤더에 보낼 때 다음 형식으로 보낸다.
Authorization: "Bearer access-token"
여기서 Bearer 키워드는 토큰의 타입을 말한다. JWT나 OAuth에 대한 토큰을 나타내는 것에 사용한다. (RFC 6750)
그렇기 때문에 이 키워드를 파싱해야 한다.
private String bearerParse(String accessToken) {
return accessToken.replace("Bearer ", "");
}
이제 엑세스 토큰을 이용해 사용자 번호를 가져와보자.
public Long getUserId(String accessToken, String refreshToken) {
String parsedAccessToken = bearerParse(accessToken);
validateTokens(parsedAccessToken, refreshToken);
if (tempStore.get(refreshToken) == null) {
throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
}
return Long.parseLong(parseToken(parsedAccessToken).getPayload().get("id", String.class));
}
토큰을 발급받을 때 사용자 번호를 페이로드에 저장했었다. 따라서 파싱 후 페이로드에 접근해서 사용자 아이디를 가져오면 된다. 또한 저장소에서 리프레시 토큰을 키 값으로 한 결과가 null이라면 토큰이 탈취되었다고 가정하여 예외를 반환하도록 하였다.
토큰을 재발급 받을 때는 별도의 저장소에서 저장한 사용자 번호 값을 가져와서 재발급 받으면 된다.
public String regenerateAccessToken(String refreshToken) {
validateRefreshToken(refreshToken);
String userId = String.valueOf(tempStore.get(refreshToken));
if (userId == null) {
throw new TokenTheftException(REFRESH_TOKEN_EXPIRED);
}
return createToken(userId, accessTokenExpirationTIme);
}
사용자가 로그아웃을 할 때 임시 저장소에서 토큰을 제거해야 한다.
public void cacheOutRefreshToken(String refreshToken) {
tempStore.remove(refreshToken);
}