Backend Engineering

[Serverless 서비스 개발] #5. Token Refresh

zamezzz 2025. 6. 23. 15:52

안녕하세요

 

오늘은 지난 Login / Logout에 이어서 해당 JWT Token을 Refresh 하는 방법에 대해 작성해보려고 합니다.

 

아시다시피 JWT 기반 인증을 사용하면 accessToken은 stateless하게 동작해서 서버 부하가 적고, 속도도 빠릅니다.
하지만 짧은 유효 시간 때문에 일정 시간이 지나면 다시 로그인해야 하는 번거로움이 생깁니다.
이를 해결하기 위해 사용하는 것이 refreshToken입니다.

 

▶️ Refresh API가 필요한 이유

JWT는 자체적으로 만료 시간을 가지기 때문에, accessToken은 일반적으로 15분~30분 정도로 짧게 설정합니다.

(저는 지난 개발에서 15분으로 설정 했습니다.)


하지만 사용자가 매 15분마다 다시 로그인한다면 UX는 매우 나빠집니다. 그래서 긴 유효기간을 가진 refreshToken을 같이 발급하고,
accessToken이 만료됐을 때 refreshToken으로 새로운 accessToken을 발급받게 됩니다.

 

즉, refresh API는 인증의 연속성을 유지하면서도, 보안성을 확보하기 위한 중요한 역할을 합니다.

 

▶️ accessToken 재발급 흐름

accessToken이 만료되었을 때, 클라이언트는 refresh API를 호출해서 새로운 accessToken을 발급받습니다.
전체 흐름은 아래와 같습니다.

 

이때 refreshToken도 만료됐거나, 저장된 값과 다르면 응답은 401 Unauthorized로 처리하고, 로그아웃을 시킬 예정입니다.

 

클라이언트는 accessToken이 만료됐을 때만 refresh API를 호출합니다.

위의 그림에서 Client가 accessToken 만료 감지를 할 수 있는 시점은 사용하던 API를 호출 했을 때 401 응답을 받을 때 입니다.

 

그래서 매 API 호출에 대해 아래와 같은 로직을 구현해야 합니다.

  1. API 요청 시 401 Unauthorized가 떨어짐
  2. 클라이언트는 refreshToken으로 refresh API를 호출
  3. 새 accessToken을 받으면, 실패했던 요청을 재시도

 

▶️ refreshToken을 DB에 저장하는 이유

지난 로그인을 만들 때 refreshToken을 DB에 저장한다고 했습니다.

JWT는 원래 서버가 상태를 저장하지 않는 stateless 방식입니다. 그런데 왜 refreshToken은 DynamoDB에 저장했을까요?

 

🔑 이유 1. 로그아웃 처리

accessToken은 stateless라 강제 로그아웃이 어렵습니다.
하지만 refreshToken을 저장해두면 해당 값을 삭제하거나 무효화할 수 있습니다.

 

🔑 이유 2. 탈취 대응

refreshToken이 탈취될 경우, 저장된 값과 비교하여 유효성을 판단할 수 있어 보안에 유리합니다.
클라이언트에서 아무리 유효해 보여도, DB에 없으면 무효 처리하면 되니까요.

 

🔑 이유 3. 접속 제어

향후 IP, 기기 정보 등을 함께 저장해두면, 동시 접속 제어 또는 이상 접근 탐지 등의 기능도 구현할 수 있습니다.

 

 

▶️ refreshTokenHandler

그럼 위 내용을 바탕으로 작성한 refreshTokenHandler 코드를 같이 보고 정리하겠습니다.

public class RefreshTokenHandler implements RequestHandler<Map<String, Object>, Map<String, Object>> {

    private static final ObjectMapper objectMapper = new ObjectMapper();
    private final UserRepository userRepository = new UserRepository(); // DynamoDB 연동용

    @Override
    public Map<String, Object> handleRequest(Map<String, Object> event, Context context) {
        Map<String, String> headers = (Map<String, String>) event.get("headers");
        Map<String, Object> response = new HashMap<>();

        String authHeader = null;
        if (headers != null) {
            for (Map.Entry<String, String> entry : headers.entrySet()) {
                if ("authorization".equalsIgnoreCase(entry.getKey())) {
                    authHeader = entry.getValue();
                    break;
                }
            }
        }

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.put("statusCode", 400);
            response.put("body", "{\"message\": \"Authorization header missing\"}");
            return response;
        }

        String refreshToken = authHeader.substring(7); // "Bearer " 이후
        String userId = JwtUtil.extractUserId(refreshToken);
        if (userId == null || !JwtUtil.validateToken(refreshToken)) {
            response.put("statusCode", 401);
            response.put("body", "{\"message\": \"Invalid or expired refresh token\"}");
            return response;
        }

        // 저장된 refreshToken과 비교
        User user = userRepository.getUser(userId);
        if (user == null || !refreshToken.equals(user.getRefreshToken())) {
            response.put("statusCode", 403);
            response.put("body", "{\"message\": \"Refresh token mismatch\"}");
            return response;
        }

        // accessToken 재발급
        String newAccessToken = JwtUtil.generateAccessToken(userId);

        Map<String, String> body = new HashMap<>();
        body.put("accessToken", newAccessToken);

        try {
            response.put("statusCode", 200);
            response.put("body", objectMapper.writeValueAsString(body));
        } catch (Exception e) {
            response.put("statusCode", 500);
            response.put("body", "{\"message\": \"Internal server error\"}");
        }

        return response;
    }
}

 

 

 

▶️ Serverless 서비스 정리

여기까지 우선 개발을 마치면, 간단하게 Serverless 서비스가 하나 완성이 됩니다.

5개의 블로그 글로 빠르고 간단하게 만들어보았는데요.

 

그래도 회원가입부터 로그인/로그아웃 그리고 토큰 갱신 API를 serverless 구조로 서비스 가능한 수준으로 만들었습니다.

(그리고 아마 비용도 무료입니다)

 

이제 여기서 각 서비스별로 필요한 서비스 API만 만들면, 이 serverless 인증을 통해 편하게 운영할 수 있을 것 입니다.

 

다음 몇 개의 추가 글에서는 좀 더 serverless에 대한 얘기를 작성해보려고 하는데요.

API Gateway와 Lambda의 성능 개선을 위해 할 수 있는 일 이라던가 

CloudWatch를 통한 로깅과 모니터링 / 알람 받기 등에 대한 일에 대해서 작성해보겠습니다.

 

아마 이 상태로 운영이되면 콜드 스타트 등의 이슈로 최초 유저 등은 응답시간이 꽤 느릴거에요.

이를 해결하는 일반적인 몇 개의 방법이 있으니, 이를 알아보겠습니다.

 

그럼 감사합니다.