목록을 보여줄때 무한 스크롤 방식으로 보여주는것이 사용성 면에서 좋을 것 같아 모든 목록 조회는 무한 스크롤 방식을 사용하기로 결정했습니다. 무한 스크롤 방식을 구현하기 전에 Offset 페이징No offset 페이징 기법의 차이에 대해 간단히 알아보겠습니다.

📃 페이징 기법

Offset

Offset과 Limit을 활용한 페이징 기법의 경우 다음과 같은 형태로 쿼리가 생성됩니다.

SELECT ...
FROM ...
WHERE ...
ORDER BY id DESC
OFFSET {page_number}
LIMIT {page_size}

이경우 page_number에 해당하는 행만큼 데이터를 읽어들인 후 다시 page_size만큼의 행을 읽고 앞에 읽은 행을 삭제하는 과정을 거치게 되는데, 데이터가 많아 질수록 읽어야 할 데이터의 개수가 많아지게 되고 결국 데이터 베이스에 많은 부하를 주게 됩니다.

또한 새로운 페이지를 가져오는 중간에 새로운 행이 삽입되면 새로운 페이지에는 이전 페이지와 중복되는 행이 존재하게 됩니다.

image

첫 페이지에서는 10번 Row 까지 조회한 후 다음 페이지를 조회하기 전, 새로운 행이 삽입됩니다. offset을 기준으로 다음 페이지의 행을 조회하게 되기 때문에 10번 Row는 다음페이지에서도 조회되는 중복조회 현상이 발생할 수 있습니다.

No Offset

만약 Offset을 사용하지 않는다면, 마지막 조회한 행의 id값을 기준으로 읽지 않은 행을 page size만큼 조회하면 됩니다. 이를 No Offset방식이라고 하며 id라는 클러스터 인덱스를 사용하기 때문에 시작부분을 빠르게 찾아 조회가 가능합니다.

No offset 방식의 쿼리 예시

SELECT ...
  FROM ...
 WHERE ...
   AND id < ?last_seen_id
 ORDER BY id DESC
 FETCH FIRST 10 ROWS ONLY

페이지 수에 따른 응답시간 비교

8517F1DF-5539-4F22-9C06-B600D402DA96

Offset방식을 사용하였을때는 Page의 수가 늘어남에 따라 응답시간이 느려지는것을 볼 수 있지만, 인덱스(id)를 기준으로 정렬하고 조회하는경우 첫페이지를 읽는것과 동일한 속도로 응답하는것을 확인할 수 있습니다.

성능면에서는 이러한 장점이 있지만 순차적으로만 다음페이지를 조회할 수 있다는 단점 또한 존재합니다.

결론 : 무한 스크롤 방식에서는 페이지 버튼이 필요 없기 때문에 성능상 우수한 no offset방식으로 구현하는것이 적절해 보입니다.

💻 구현

BoardQueryRepository구현

@Repository
@RequiredArgsConstructor
public class BoardQueryRepository {

  private final JPAQueryFactory jpaQueryFactory;

  public List<Board> getSliceOfBoard(
      @Nullable
      Long id,
      int size,
      @Nullable
      String keyword
  ) {
    return jpaQueryFactory.selectFrom(board)
                          .where(ltBoardId(id), search(keyword))
                          .orderBy(board.id.desc())
                          .limit(size)
                          .fetch();
  }

  @SuppressWarnings("all")
  private BooleanExpression search(String keyword) {
    return containsTitle(keyword).or(containsDescription(keyword));
  }

  private BooleanExpression containsTitle(@Nullable String title) {
    return hasText(title) ? board.title.contains(title) : null;
  }

  private BooleanExpression containsDescription(@Nullable String description) {
    return hasText(description) ? board.description.contains(description) : null;
  }

  private BooleanExpression ltBoardId(@Nullable Long id) {
    return id == null ? null : board.id.lt(id);
  }

}

첫 요청

[
    {
        "id": 42,
        "title": "Lodge",
        "description": "Malawi matrix Cambridgeshire"
    },

    ...

    {
        "id": 33,
        "title": "Avon",
        "description": "synergies Money complexity generation"
    }
]

2번째 요청

[
    {
        "id": 32,
        "title": "holistic",
        "description": "analyzing Fresh Senior facilitate Practical"
    },

    ...

    {
        "id": 23,
        "title": "Bedfordshire",
        "description": "Jewelery Pants Liechtenstein Metal South"
    }
]

검색어를 포함한 요청

[
    {
        "id": 23,
        "title": "Bedfordshire",
        "description": "Jewelery Pants Liechtenstein Metal South"
    },
    {
        "id": 19,
        "title": "Oklahoma",
        "description": "primary Pants"
    },
    {
        "id": 13,
        "title": "Pants",
        "description": "B2C Harbors Kuwaiti"
    }
]

⌨️ 코드

Spring-Board/BoardQueryRepository.java at main · waterfogSW/Spring-Board · GitHub

☎️ Reference

이전에 스프링 시큐리티를 활용해 프로젝트를 진행하면서 컨트롤러 메서드가 추가될 때마다 매번 엔드포인트와 해당 엔드포인트에 대한 권한 설정을 SecurityConfig에 추가적으로 진행해줘야 했던 부분이 불편하다 생각했다.

 

무엇보다 컨트롤러 메서드와 관련된 로직이 컨트롤러가 아니라SecurityConfig클래스에 동떨어져 있어 매번 메서드가 추가될 때마다 설정 클래스를 같이 수정해 주어야 했다.

...
    http.authorizeRequests()
        .antMatchers(HttpMethod.POST, "/api/v1/products").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/bidding").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/users/{userId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/products/{productId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/reports/comments/{commentId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.POST, "/api/v1/comments").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/products/{productId}/result").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/biddings/products/{productId}").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/chatRooms").hasAnyRole("USER")
        .antMatchers(HttpMethod.GET, "/api/v1/chatRooms/**").hasAnyRole("USER")
...

🤔 컨트롤러 메서드에 다음과 같이 간단하게 어노테이션을 붙여서 권한 관련 설정을 응집도 있게 구현할 수는 없을까?

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

  @Auth(role = USER)
  @GetMapping("posts")
  public String getPosts() {
    ...
  }

}

✅ 요구사항

  • 헤더로 전달되는 JWT 토큰을 통해 인증, 인가를 진행하고 있고, 토큰 내에 권한(Role)이 들어있는 상태다.
  • 컨트롤러의 메서드에 도달하기전 토큰에 들어있는 권한을 확인하고 어노테이션의 권한과 일치하지 않으면 예외를 발생시킨다.
  • 권한과 일치하는 경우 해당 요청의 컨텍스트에 토큰 내 유저 정보(userId, role)를 ThreadLocal에 저장한다.
  • 스프링 시큐리티는 사용하지 않는다.

💻 구현

우선 컨트롤러의 메서드에 요청이 도달하기 전에 헤더의 토큰을 확인하고 인가를 진행해야 한다. 즉 컨트롤러 메서드 앞단에서 인가와 관련된 부가기능을 부여해 주어야 하는데 이때 사용할 수 있는 대표적인 방법이 Filter, Interceptor, AOP가 있다.

  • Filter의 경우 디스패처 서블릿 앞단에서 수행되므로 매핑할 핸들러 메서드에 대한 정보를 얻을 수 없다.
  • AOP를 활용해 부가기능을 적용하기 위해서는 어디에 부가기능을 적용할지 결정하는 포인트 컷을 작성해 주어야 한다. 컨트롤러는 리턴 타입과 파라미터가 일정하지 않아 포인트 컷의 작성이 어렵다.
  • Interceptor의 경우 스프링 컨텍스트에서 관리하기 때문에 빈을 주입받을 수 있고, 엔드포인트와 메서드에 따른 적절한 핸들러를 매핑한 이후에 동작하기 때문에 핸들러에 대한 정보를 얻을 수 있다. 즉 어노테이션 정보를 얻을 수 있다.

위와 같은 이유로 Interceptor를 통해 인가 기능을 구현하였다. Interceptor를 구현하기 위해선 HandlerInterceptor를 구현해야 한다. HandlerInterceptor는 다음의 3가지 메서드를 제공한다.

  • preHandle(..): 핸들러 동작전 실행할 로직을 정의
  • postHandle(..): 핸들러 동작후 실행할 로직을 정의
  • afterCompletion(..): 요청이 끝난 후 실행할 로직을 정의

핸들러 동작전 요청에 대한 인가를 진행해야 하므로 preHandle() 메서드에 관련 로직을 구현하였다.

@Override
public boolean preHandle(
    HttpServletRequest request,
    HttpServletResponse response,
    Object handler
) {
  //1. 형변환 가능한지 확인
  if (!(handler instanceof HandlerMethod handlerMethod)) {
    return true;
  }

  //2. 권한이 필요한 메서드인지 확인(Auth어노테이션이 붙어있는지 확인)
  if (isNeedsAuthorization(handlerMethod)) {
    return true;
  }

  //3. 헤더에서 토큰 가져오기
  String token = extractTokenFromHeader(request);

  //4. 토큰 확인
  if (!authenticationTokenResolver.validateToken(token)) {
    throw new AuthenticationException();
  }

  //5. 인가 가능 여부 체크
  Authentication authentication = authenticationTokenResolver.getAuthentication(token);
  Role clientRole = authentication.role();
  Role handlerRole = getMethodRole(handlerMethod);

  if (!clientRole.hasAuthority(handlerRole)) {
    throw new AuthenticationException();
  }

  //6.
  AuthenticationContextHolder.setAuthentication(authentication);

  return true;
}
  1. HandlerMethod 인스턴스인지 확인한다
  2. 해당 메서드의 어노테이션에 @Auth가 있는지 확인한다.
  3. 헤더로부터 토큰을 가져온다.
  4. 토큰 위변조 및 만료 여부를 검증한다.
  5. 토큰의 권한이 핸들러의 권한보다 높은지 확인한다.
  6. 토큰을 통해 얻은 정보(Authentication)해당 요청 사이클에서 활용할 수 있도록 AuthenticationContextHolder에 넣는다.

handlerMethod의 getMethodAnnotation을 통해 어노테이션에 지정된 권한을 확인하였다.

private Role getMethodRole(HandlerMethod handlerMethod) {
  return Objects.requireNonNull(handlerMethod.getMethodAnnotation(Auth.class))
                .role();
}

AuthenticationContextHolder
SpringSecurityContextHolder를 참고하여 작성하였다.

public class AuthenticationContextHolder {

  private static final ThreadLocal<Authentication> authenticationHolder = new ThreadLocal<>();

  public static void clearContext() {
    authenticationHolder.remove();
  }

  public static Authentication getAuthentication() {
    Authentication authentication = authenticationHolder.get();
    if (authentication == null) {
      authentication = Authentication.createEmptyAuthentication();
      authenticationHolder.set(authentication);
    }
    return authentication;
  }

  public static void setAuthentication(Authentication authentication) {
    authenticationHolder.set(authentication);
  }

}

스프링 부트의 WAS(톰캣)은 각 요청마다 Thread Pool에서 Thread를 할당받아 사용하고 응답이 끝나면 Thread를 ThreadPool에 반환한다. 이때 ThreadLocal을 통해 저장한 권한 정보를 제거하지 않으면 다음번 요청에 해당 스레드를 재사용하는 경우 다른 사용자가 해당 스레드의 권한 정보를 얻게 되는 문제가 발생할 수 있다.

 

이러한 문제를 방지하기 위해서 응답이 종료되면 반드시 ThreadLocal.remove()를 통해 ThreadLocal의 값을 제거해 주어야 한다. 그래서 핸들러가 요청을 처리하고 return하면 ThreadLocal의 값을 제거하도록 인터셉터의 postHandler() 로직을 다음과 같이 구현하였다.

...
@Override
public void postHandle(
    HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    ModelAndView modelAndView
) {
  AuthenticationContextHolder.clearContext();
}
...

인터셉터 등록
WebMvcConfigure를 구현하여 작성한 인터셉터를 등록하였다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

  private final AuthenticationInterceptor authenticationInterceptor;

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authenticationInterceptor);
  }

}

📸 동작 확인

테스트 핸들러를 작성하고 동작을 확인하였다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class TestController {

  @Auth(role = USER)
  @GetMapping(“login”)
  public String resolveToken() {
    Authentication heldAuthentication = AuthenticationContextHolder.getAuthentication();
    log.info(“held authentication={}”, heldAuthentication);

    return “success”;
  }

}

로그

2022-11-30 10:02:19.231  INFO 21571 --- [nio-8081-exec-7] c.waterfogsw.board.user.TestController   : held authentication=Authentication[userId=1, role=USER]

image

🤦🏻 문제점

위와같이 인터셉터를 구현하고 컨트롤러 메서드를 구현하면서 다음과 같은 문제점들이 있음을 느꼈다.

  • @Auth가 메서드에 붙어있지 않으면 토큰을 Resolve 하여 AuthenticationContextHolder에 정보를 저장하는 인증 과정이 진행되지 않는다.
    • 토큰을 전달했음에도 불구하고 컨텍스트에서 유저 정보를 조회할 수 없는 상황이 발생할 수 있음.
  • Token Resolve를 하는 과정이 인터셉터에서 진행되어 Spring Web MVC에 종속적이다.
  • 해당 인터셉터 클래스의 책임이 2가지 이상이다.
    • 토큰을 Resolve하여 회원 정보를 컨텍스트에 저장하는 책임
    • 권한을 확인하여 메서드 실행 인가를 하는 책임

 

필터에서 인증과정을 진행하자


우선 위와 같은 문제를 해결하기 위해서 Spring Security를 참고했다. Spring Securtiy는 서블릿 필터를 기반으로 인증과정을 진행하기 때문에 Spring Web MVC와 같은 의존성에 종속적이지 않다는 장점이 있다.

 

@Auth어노테이션을 통해 인가를 진행하는 과정은 핸들러 메서드를 기반으로 하기 때문에 인터셉터로 구현할 필요성이 있지만, 인증을 진행하는 과정까지 인터셉터로 구현하여 Web MVC에 종속적으로 구현할 필요는 없다고 판단했다.

 

필터는 서블릿 컨텍스트에서 동작하기 때문에 빈을 주입받을 수 없다고 알고 있었지만, 간단하게 @Component어노테이션을 통해 컴포넌트 스캔의 대상으로 하여 빈으로 등록할 수 있다. 스프링 부트는 임베디드 서블릿 컨테이너를 사용하기 때문에 필터와 서블릿도 빈으로 등록하여 사용할 수 있다. (참고 : 8.1.3. Embedded Servlet Container Support)

 

따라서 다음과 같이 인증을 진행하는 로직을 필터로 분리하였다.

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {

  private static final String TOKEN_HEADER = "Authorization";
  private final AuthenticationTokenResolver tokenResolver;

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

    try {
      String token = extractTokenFromHeader(request);
      if (!tokenResolver.isTokenExpired(token)) {
        throw new JwtException("Invalid token exception");
      }
      Authentication authentication = tokenResolver.getAuthentication(token);
      AuthenticationContextHolder.setAuthentication(authentication);
    } catch (Exception e) {
      log.debug(e.getMessage());
    }

    doFilter(request, response, filterChain);
    AuthenticationContextHolder.clearContext();
  }
...

기존 인터셉터의 책임은 인가로 축소하였다.

@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(
      HttpServletRequest request,
      HttpServletResponse response,
      Object handler
  ) {
    if (!(handler instanceof HandlerMethod handlerMethod)) {
      return true;
    }

    if (isNeedsAuthorization(handlerMethod)) {
      return true;
    }

    Authentication authentication = AuthenticationContextHolder.getAuthentication();
    Role clientRole = authentication.role();
    Role handlerRole = getMethodRole(handlerMethod);

    if (!clientRole.hasAuthority(handlerRole)) {
      throw new AuthenticationException();
    }

    AuthenticationContextHolder.setAuthentication(authentication);

    return true;
  }

🔠 코드

https://github.com/waterfogSW/Spring-Board/blob/main/board-api-server/src/main/java/com/waterfogsw/board/common/auth/AuthenticationInterceptor.java

Reference

+ Recent posts