[JWT] 세션 의존성 제거하기 - 커스텀 어노테이션 활용

LJH·2022년 6월 6일
0

1. why - 세션 의존성 제거하는 이유?

토큰 기반 인증 방식으로 소셜 로그인 및 회원가입 기능을 구현했다.

여러 레퍼런스를 참고하며, 토큰을 세션에 저장하고 세션에서 해당 토큰을 꺼내서

현재 로그인한 사용자를 가져오도록 구현을 했다.

그런데 세션 기반 인증방식의 문제점을 해결하기 위해 토큰 기반 인증방식이 등장했는데,

두 방식을 혼용해서 사용하고 있는게 이상하다는 생각이 들었다.

1-1. 세션 기반 인증 방식 문제점

세션 기반 인증 방식에서의 가장 큰 문제점 중 하나는 http의 stateless를 위배한다는 것이다.

stateless란 서버는 클라이언트의 상태를 저장하지 않는다는 의미이다.

하지만 세션 저장소라는 곳에서 클라이언트의 상태를 저장하게 되므로 stateful하게 된다.

이게 무슨 문제야? 라고 생각할 수 있는데 간단한 예를 들어보면

서버를 scale-out한 경우 1번 서버에 로그인한 사용자가 다른 2번 서버로

요청을 보내게 된다면, 2번 서버에는 로그인 상태가 남아있지 않기 때문에 다시 로그인해야

하는 상황이 발생한다. 즉 확장성에 제약이 생긴다.

1-2. 현재 문제

filter에서 클라이언트로부터 넘어온 토큰을 꺼내서 세션에 저장함으로 인해, stateful

하게 되면서 JWT를 도입한 의미를 퇴색시킬 뿐 아니라, 위에서 이야기한

세션을 사용함으로써 발생하는 문제점을 그대로 가져간다는 문제점이 존재한다고 생각했다.

물론 현재는 단일 서버이므로 문제 되지는 않는다. 하지만 실제 서비스는 웬만해선 서버

한대로 운영 하지 않는다. 설령 초기에는 한대로 운영하더라도 사용자가 늘어나고

트래픽이 증가할수록 scale-out을 피할수는 없을 것 이다.

언제나 확장 가능하도록 유연하게 설계해야 하므로, Sessionless를 통해 stateless하도록

만들어보고자 한다.

1-3. 해결 방법

떠오른 방법은 아래와 같다.

  1. 각 API에서 헤더에 있는 토큰을 꺼내고, 토큰에서 식별자를 추출해 사용자 정보를 가져오는 방법

    • 모든 API에서 동일한 코드가 반복된다.
  2. 커스텀 어노테이션을 통해 사용자 정보를 가져오는 방법

    • 모든 API에서 동일한 코드가 반복되는걸 막기 위해, 커스텀 어노테이션을 만들어서
      파라미터에서 사용자 정보를 가져올 수 있도록 하는 방법이 best practice라고 생각했다.

2. Argument Resolver

커스텀 어노테이션을 만들어보기 전에, Argument Resolver가 뭔지 알아보자.

2-1. 정의

  • Argument Resolver는 API 엔드포인트로 인입된 데이터를 가공 및 바인딩 할 때 사용하는 객체이다.

  • http body 또는 url 파라미터로 넘어오는 데이터들은, @ReqeustBody와 @RequestParam 등으로 바인딩 할 수 있지만 http 헤더, 쿠키, 세션 등으로 전달되는 데이터인 경우에는 Argument Resolver를 이용할 수 있다.

  • 대표적으로 세션에서 로그인한 사용자의 정보를 얻거나, 헤더로 전달되는 토큰에서 사용자의 정보를 얻을 때 사용한다.

2-2. Resolver 호출 시기

  • Spring MVC Flow이다.

  • 여기서 Argument Resolver가 호출되는 시기는
    handler adapot를 찾은 이후 Argument Resolover가 처리되고, 이후 Handler를 실행한다.

    • 쉽게 이야기하면 컨트롤러의 메서드를 찾고 → Argument Resolver 처리 → 컨트롤러 메서드 실행 이라고 보면 되겠다.

3. 커스텀 어노테이션 만들기

3-1. 어노테이션 선언

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthAccount {
}

@Target

이름설명
PACKAGE패키지 선언 시
TYPE타입(클래스, 인터페이스, enum) 선언 시
CONSTUCTOR생성자 선언 시
FIELDenum 상수를 포함한 멤버변수 선언 시
METHOD메소드 선언시
ANNOTATION_TYPE어노테이션 타입 선언 시
LOCAL_VARIABLE지역변수 선언 시
PARAMETER파라미터 선언 시
TYPE_PARAMETER파라미터 타입 선언 시

@Retention

이름설명
RUNTIME컴파일 이후에도 참조 가능
CLASS클래스를 참조할 때 까지 유효
SOURCE컴파일 이후 어노테이션 정보 소멸

3-2. Resolver 구현

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthAccountResolver implements HandlerMethodArgumentResolver {

    private final JwtService jwtService;
    private final TokenRepository tokenRepository;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAnnotation = parameter.hasParameterAnnotation(AuthAccount.class);
        boolean isAccountType = Account.class.isAssignableFrom(parameter.getParameterType());

        return hasAnnotation && isAccountType;
    }

    @Override // JwtFilter에서 모두 검증하므로, 검증 로직은 추가하지 않음
    public Account resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                   NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        String jwt = JwtUtils.resolveTokenByWebRequest(webRequest);
        String sub = jwtService.getSubject(jwt);
        Token token = tokenRepository.findByUniqueIdBySocial(sub).orElseThrow(AccountNotFoundException::new);

        return token.getAccount();
    }

}
  • supportsParameter()

    • 컨트롤러의 메서드에 존재하는 파라미터들에 대해서 검사하여, 적용 여부를 판단한다.

      1. AuthAccount.class (커스텀 어노테이션이) 있는지

      2. 파라미터 타입이 Account.class 인지

  • resloveArgument()

    • supportsParameter() 가 true값을 return하면 실행되는 메서드이다.

    • 파라미터에 전달할 객체를 return한다.

    • 토큰에 대한 검증은 filter에서 처리하므로, 검증 로직은 제외했다.

3-3. Resolver 추가

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final AuthAccountResolver authAccountResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(authAccountResolver);
    }

}
  • 당현히 구현한 Reslover를 등록해줘야 동작한다.

  • WebMvcConfigurer의 구현체를 만들고, addArgumentResolvers 메서드를 오버라이딩 하여
    List에 구현체를 추가해주면 된다.


4. 사용

@PostMapping("/test")
public String test(@AuthAccount Account account){
	log.info(account.getId())
    return "ok";
}
  • 컨트롤러 메서드 파라미터에 적용하면 된다.

  • relover에서 헤더로 전달되는 토큰의 고유 id값을 추출하여, DB에서 조회 후
    Account 객체를 리턴해준다.


5. 마치면서 문제점?

5-1. refresh token으로 인한 여전한 stateful

refresh token 개념을 사용하게 되면, refresh token을 어딘가에 저장해야 하므로

statueful 하게 된다. 그래도 refresh token을 서버가 아닌 DB에 저장하므로

서버를 scale-out 하는 경우에는 제약이 되지 않는다는 세션과의 차이점이 있다.

그리고 DB는 서버보다는 확장 가능성이 적고?, 클러스터링을 통해 어느정도 해결할 수 있다.

5-2. DB I/O 증가

세션에서 꺼내오는게 아니라, DB에 있는 refresh token으로 사용자의 정보를 조회해오므로

DB를 조회해야 한다는 단점이 있다.

단일 서버로 운영할 것이고, 추후에도 확장 가능성이 확실히 없다면(백오피스 서버?) 토큰과 세션을 함께 사용하는게 더 나을수도 있겠다는 생각이 든다.

하지만 서버의 확장 가능성이 존재한다면, 세션을 제거하고 토큰 기반 인증 방식을 사용하는게 조금 더 나은 방법이 아닐까 생각한다.


Ref

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

스프링 부트 Custom Annotation(커스텀 어노테이션), GET 요청 ~ JSON 응답

Spring/스프링 MVC 흐름 - Dispatcher Servlet

0개의 댓글