👻 Java Restdocs코드

junit5로-계층-구조의-테스트-코드-작성하기 - 기계인간 John Grib글을 보고 난 후 계층형으로 테스트 코드를 작성해 보았는데, 테스트 결과가 한눈에 파악하기 좋게 트리구조로 출력되어서 다음과 같은 DCI패턴의 계층형 테스트를 선호하게 되었다.

@Nested
@DisplayName("POST")
class DescribePOST {

  @Nested
  @DisplayName("유효한 요청이 전달되면")
  class ContextWithValidRequest {
    setAuthenticationHolder(1L, Role.USER);
    final var requestBody = new BoardCreateRequest("Test", "Test");
    final var jsonBody = objectMapper.writeValueAsString(requestBody);

    final var request = RestDocumentationRequestBuilders
          .post(URL)
          .contentType(MediaType.APPLICATION_JSON)
          .content(jsonBody);

    @Test
    @DisplayName("201 응답")
    void ItResponse201() throws Exception {
      final var response = mockMvc.perform(request);

      response.andExpect(status().isCreated())
              .andDo(document("Create Board", requestFields(
                  fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                  fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
              )));
    }

  }

 

하지만 간단하게 주석으로 다음과 같은 스타일의 테스트 코드를 작성하는 것에 비해 @Nested어노테이션을 매번 붙여주어야 하고, 클래스명들도 정의해 주어야 해서 테스트 작성시간이 오래 걸린다는 생각이 들었다.

@Test
void test() {
  //given
  ...
  //when
  ...
  //then
  ...
}

물론 인텔리제이 Live template을 활용해 모든코드를 일일이 작성하지는 않았지만 테스트 코드 외 불필요한 코드들이 여전히 많은 수의 라인을 차지하고 있어 테스트 코드에 온전히 집중하기 힘들었다.

 

이러한 고민을 하던 찰나에 Kotest에서는 DCI패턴의 테스팅 스타일을 지원한다는 이야기를 듣고 찾아보게 되었다.

👍 왜 Kotest인가

  • Kotlin DSL 을 활용한 다양한 테스팅 스타일을 제공한다
  • Kotest는 Junit과 호환된다

테스팅 스타일
Kotest에는 여러 언어의 테스트프레임워크로부터 영감을 받은 테스팅 스타일을 제공한다.

Java - Junit

@Nested
@DisplayName("...")
class Describe... {

  @Nested
  @DisplayName("...")
  class ContextWith... {

    @Test
    @DisplayName("...")
    void It...("...") {
      ...
    }

  }

}

Kotlin - Kotest

describe("...") {
  context("...") {
    it("...") {
        ...
    }
  }
}

Kotlin DSL 을 활용하니 테스트 코드가 훨씬 눈에 잘 들어온다. 또 Java에서 DCI패턴을 활용할 때는 매번 클래스 명과 메서드 명을 정의해 주어야 해서 불편한 점이 있었는데 Kotest를 활용하니 이러한 점이 사라져서 좋았다.

그렇다면 이를 활용해서 기존 Junit 테스트 코드를 Kotest 테스트 코드로 리펙토링 해보자.

♻️ 리펙토링

테스트 하려는 클래스는 컨트롤러 계층의 BoardRestController클래스로 서비스 계층에 대해 다음과 같은 의존성을 갖고 있었다.

public class BoardRestController {

  private final BoardCommandService boardCommandService;
  private final BoardQueryService boardQueryService;

기존 테스트 코드는 @MockBean을 통해 두 서비스 클래스를 모킹 하고 있었는데, Koltin에서 역시 동일하게 사용가능하므로 다음과 같이 테스트를 작성해 주었다.

class BoardRestControllerTest(
  @MockBean
  private val commandService: BoardCommandService,
  @MockBean
  private val queryService: BoardQueryService,
  ...

이후 DCI 테스팅 스타일을 활용해 Restdocs코드까지 다음과 같이 작성해 주었다. @Nested와 불필요한 클래스 선언 코드들이 사라지니 조금 더 깔끔해진 느낌이다.

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDo(document("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }
  }

하지만 restDocs와 관련된 코드들이 거슬렸다. andDo(docuemnt(… 같은 형태의 메서드 호출은 반복되고 fieldWithPath... 같은 형태의 체이닝 메서드는 매번 자동 포매팅을 할 때마다 이상하게 정렬되고 한눈에 잘 들어오지 않는다는 생각이 들었다.

✚ 더 개선하기

JAVA에서는 Standard Library나 외부 라이브러리를 사용하는 경우 새로운 함수를 추가하기 어렵다. 하지만 코틀에서는 Extension functions(확장 함수)을 활용하면 기존에 정의된 클래스에 함수를 손쉽게 추가할 수 있다.

 

우선 andDo() 메서드를 가지고 있는 클래스에 코틀린 확장함수로 andDocument() 함수를 다음과 같이 구현하여 반복되는. andDo(document(... 호출을 개선하였다.

fun ResultActions.andDocument(
  identifier: String,
  vararg snippets: Snippet
): ResultActions {
  return andDo(document(identifier, *snippets))
}
it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }

다음으로 restdocs코드의 경우 infix function(중위함수)와 문자열 extension functions(확장함수)를 통해 개선할 수 있었다.

우선 description과 optional 과같은 descriptor의 체이닝 메서드들을 중위함수로 처리할 수 있도록 RestDocField를 구현하였다.

class RestDocField(
  val descriptor: FieldDescriptor
) {

  infix fun isOptional(value: Boolean): RestDocField {
    if (value) descriptor.optional()
    return this
  }

  infix fun isIgnored(value: Boolean): RestDocField {
    if (value) descriptor.ignored()
    return this
  }

  infix fun means(value: String): RestDocField {
    descriptor.description(value)
    return this
  }

  infix fun attributes(block: RestDocField.() -> Unit): RestDocField {
    block()
    return this
  }
}

그리고 문자열을 통해 descriptor를 정의할 수 있도록 문자열에 대한 확장함수를 구현하였다.

infix fun String.fieldType(
  docsFieldType: DocsFieldType
): RestDocField {
  return createField(this, docsFieldType.type)
}

private fun createField(
  value: String,
  type: JsonFieldType,
  optional: Boolean = true
): RestDocField {
  val descriptor = PayloadDocumentation
      .fieldWithPath(value)
      .type(type)
      .description("")

  if (optional) descriptor.optional()

  return RestDocField(descriptor)
}

fun requestBody(vararg fields: RestDocField): RequestFieldsSnippet =
  PayloadDocumentation.requestFields(fields.map { it.descriptor })

fun responseBody(vararg fields: RestDocField): ResponseFieldsSnippet =
  PayloadDocumentation.responseFields(fields.map { it.descriptor })

적용 후

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument(
            "Create Board", (
                requestBody(
                  "title" fieldType STRING means "게시판 이름" isOptional false,
                  "description" fieldType STRING means "게시판 설명" isOptional true
                ))
          )
    }
  }
}

좀 더 파라미터가 많은 예시

describe("GET : /api/v1/boards/{id}") {
  val url = "/api/v1/boards/{id}"
  context("유효한 요청이 전달 되면") {
    val id = 1L
    val request = request(HttpMethod.GET, url, id)
    it("200 응답") {
      val response = getTestBoardGetDetailResponse()
      given(queryService.getDetail(anyLong())).willReturn(response)
      mockMvc
          .perform(request)
          .andExpect(status().isOk)
          .andDocument(
            "Lookup Board",
            pathParameters(
              "id" pathMeans "게시판 번호"
            ),
            responseBody(
              "id" fieldType NUMBER means "게시판 번호" isOptional false,
              "title" fieldType STRING means "게시판 제목" isOptional false,
              "description" fieldType STRING means "게시판 제목" isOptional true,
              "creatorInfo.id" fieldType NUMBER means "게시판 생성자 번호" isOptional false,
              "creatorInfo.name" fieldType STRING means "게시판 생성자 이름" isOptional false,
              "creatorInfo.imageUrl" fieldType STRING means "게시판 생성자 이미지 URL" isOptional false,
              "createdAt" fieldType STRING means "게시판 생성일" isOptional false,
            )
          )
    }
  }
}

Kotest와 코틀린의 확장함수, 중위함수를 통해 기존 테스트 코드를 훨씬 가독성 있게 변경하였다. 이러한 리팩터링은 필드와 Path 파라미터들이 많이 필요할수록 눈에 띄었다. 코틀린의 특성을 활용해 DSL을 작성하고 기존 코드를 리펙토링 해보면서 코틀린의 장점을 더 크게 느낄 수 있었다.

Reference

목록을 보여줄때 무한 스크롤 방식으로 보여주는것이 사용성 면에서 좋을 것 같아 모든 목록 조회는 무한 스크롤 방식을 사용하기로 결정했습니다. 무한 스크롤 방식을 구현하기 전에 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

상속과 합성의 차이

상속 관계는 is-a관계라고 부르고 합성 관계는 has-a관계라고 부른다. 둘은 코드 재사용이라는 공통점이 있지만 구현 방식과 다루는 방식에 있어서 큰 차이를 보인다.

상속은 자식클래스가 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 부모 클래스와 강한 결합도를 가진다. 둘의 관계는 컴파일 타임에 결정된다.

반면 합성은 객체의 구현이 아닌 내부에 포함되는 인터페이스에 의존하며 객체 내부 구현이 변경되더라고 영향을 최소화 할 수 있다. 또한 런타임 시점에 동적으로 변경할 수 있어 변경하기 쉽고 유연한 설계가 가능하다.

따라서 코드 재사용을 위해서는 객체 합성이 클래스 합성보다 더 좋은 방법이다.

상속에서 합성으로 변경하기

상속의 단점

  1. 불필요한 인터페이스를 상속하게 된다.
  2. 부모클래스의 메서드 호출방법에 의존하게 된다.
  3. 부모클래스의 변경이 자식클래스에게 영향을 미치게 된다.

상속관계는 컴파일 타임에 결정되기 때문에 부가기능을 추가하고자 한다면, 모든 가능한 조합의 클래스를 생성해야한다는 단점이 있다. 합성은 컴파일 타임의 정적인 관계를 런타임의 동적인 관계로 변경하여 이러한 문제를 해결할 수 있다.

상속이 아니라 합성을 통해 기존 객체의 코드를 재사용하면서 부가기능을 추가하는 대표적인 사례가 데코레이터 패턴이다.

img

public interface Component {

    void execute();
}

public class ConcreteComponent implements Component {

    @Override
    public void execute() {
        System.out.println("Concrete");
    }
}


public class Decorator1 implements Component {

    private final Component component;

    public Decorator1(Component component) {
        this.component = component;
    }

    @Override
    public void execute() {
        System.out.println("Decorator 1");
        component.execute();
    }
}

public class Decorator2 implements Component {

    private final Component component;

    public Decorator2(Component component) {
        this.component = component;
    }

    @Override
    public void execute() {
        System.out.println("Decorator 2");
        component.execute();
    }
}

public class Main {

    public static void main(String[] args) {
        Component component = new ConcreteComponent();

        Decorator1 decorator1 = new Decorator1(component);
        Decorator2 decorator2 = new Decorator2(decorator1);
        decorator2.execute();
    }
}

위의 설계에서는 기존의 ConcreteComponent의 코드를 수정하지 않으면서 부가기능을 추가할 수 있도록 하였다. Decorator1,2 클래스는 Component를 합성관계로 가지면서 부가기능을 런타임에 추가할 수 있다.

믹스인

믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어넣어 재사용하는 기법이다. 상속과 유사해 보이지만, 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 반해 유연하게 관계를 재구성 할 수 있어 상속과 같은 결합도 문제에서 자유롭다.

이터레이터를 추상화한 예제를 통해 믹스인을 이해해보자.

이터레이터
abstract class AbsIterator {
  type T
  def hasNext: Boolean
  def next(): T
}

//이터레이터가 반환하는 항목에 함수를 적용하는 메서드(foreach)를 정의
trait RichIterator extends AbsIterator {
  def foreach(f: T => Unit): Unit = { while (hasNext) f(next()) }
}

//문자열의 캐릭터를 차례로 반환하는 이터레이터
class StringIterator(s: String) extends AbsIterator {
  type T = Char
  private var i = 0
  def hasNext = i < s.length()
  def next() = { val ch = s charAt i; i += 1; ch }
}

//문자열의 각 문자를 출력하는 예제
object StringIteratorTest {
  def main(args: Array[String]): Unit = {
    class Iter extends StringIterator("Scala") with RichIterator
    val iter = new Iter
    iter foreach println
  }
}

위는 스칼라라는 언어를 통해 믹스인을 적용한 사례이다. 스칼라에서는 with 키워드를 통해 런타임에 동적으로 부가기능을 추가할 수 있다.

class A extends Three with Four with Two 와 같이 여러 믹스인을 쌓을 수 있으며, 이러한 특징을 쌓을수 있는 변경(Stackable Modification)이라고 한다.

스칼라에서는 여러 trait를 쌓아 올렸다 하여 stackable trait pattern이라고 부르기도 한다.

Appendix

프록시(데코레이터 패턴 vs 프록시 패턴 정리)

스칼라 문법

파이썬에서의 믹스인

'Book Review' 카테고리의 다른 글

[오브젝트] 객체, 설계  (0) 2022.08.28
김영한님의 스프링 핵심원리 고급편 강의를 들으면서 학습한 내용을 정리한 글입니다.

 

 

 

프록시(Proxy)란 어떤 것을 직접 호출하지 않고 대리자를 통해 간접적으로 호출할 때 대리자에 해당하는 개념입니다. 네트워크에서는 보안상의 문제를 해결하기 위해 클라이언트가 직접 서버에 접근하지 않고 프록시 서버를 거쳐 접근하곤 합니다.

 

직접 호출
클라이언트와 서버 개념에서 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받는 것

 

간접 호출
클라이언트가 요청을 서버에 직접 하는 것이 아니라, 대리자를 통해서 간접적으로 서버에 요청하는 것. 이때 대리자를 프록시라 합니다.

 

프록시의 주요 기능

  • 접근제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해 부가 기능을 수행
    • ex) 요청, 응답을 변형.
    • ex) 실행 시간을 측정해 추가 로그를 남긴다.
  •  

프록시 패턴


https://javadevcentral.com/proxy-pattern-vs-decorator-pattern

프록시 패턴은 실제객체(Service)를 Proxy 객체가 감싸고 있는 형태로 실제 객체에 대한 접근제어를 프록시가 진행하게 됩니다.

 

프록시 캐시 적용 전

프록시를 적용하기 전에는 클라이언트가 실제 객체(RealSubject)를 직접 호출 하여 데이터("data")를 가져오는 형태로 작성하였습니다. 매번 데이터를 직접 접근하여 가져오게 되기 때문에 접근횟수만큼 지연시간이 길어진다는 단점이 있습니다.

 

> 접근 과정 : Client -> ChacheProxy -> RealSubject

 

RealSubject.java

@Slf4j
public class RealSubject implements Subject {

  @Override
  public String operation() {
    log.info("실제 객체 호출");
    sleep(1000); //DB 접근 지연시간
    return "data";
  }

  private void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

}
16:55:44.350 [Test worker] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
16:55:45.358 [Test worker] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
16:55:46.365 [Test worker] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출

 

프록시 캐시 적용 후

프록시 패턴을 통해 캐시를 적용하였습니다. 한번 DB에 접근한 후에는 프록시 객체의 메모리 캐시에 데이터를 저장해 두고 이후 호출에서는 DB를 호출하지 않고 캐시의 데이터를 반환하도록 설계하였습니다


> 접근 과정 : Client -> ChacheProxy -> RealSubject

 

ChachProxy.java

@Slf4j
public class CacheProxy implements Subject {

  private Subject subject;
  private String cache; //메모리 캐시

  public CacheProxy(Subject subject) {
    this.subject = subject;
  }

  @Override
  public String operation() {
    log.info(“프록시 객체 호출”);
    if(cache == null) {
      return cache = subject.operation();
    }
    return cache;
  }

}
16:55:43.312 [Test worker] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 객체 호출
16:55:43.319 [Test worker] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
16:55:44.324 [Test worker] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 객체 호출
16:55:44.325 [Test worker] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 객체 호출

실제 객체의 호출은 한번만 이루어지고 이후에는 프록시 객체만을 호출하는 형태로 DB의 호출 횟수를 줄일 수 있었습니다. 실제로 JPA에서는 영속성 컨텍스트를 통해 1차캐시기능을 제공하여 DB접근 병목을 해결합니다. 

 

 

데코레이터 패턴


https://refactoring.guru/design-patterns/decorator

데코레이터 패턴은 상황과 용도에 다라 객체에 부가기능을 동적으로 부여하기 위해 사용됩니다.

 

데코레이터 패턴 적용

데코레이터 패턴을 적용하여 실제 객체(RealComponent)에 부가기능을 적용해 보았습니다.

 

> 접근 과정 : Client -> TimeDecorator -> MessageDecorator -> RealComponent

 

RealComponent.java

@Slf4j
public class RealComponent implements Component {

  @Override
  public String operation() {
    log.info(“RealComponent 실행”);
    sleep(1000);
    return “data”;
  }

  private void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }
}

MssageDecorator.java

@Slf4j
public class MessageDecorator implements Component {

  private Component component;

  public MessageDecorator(Component component) {
    this.component = component;
  }

  @Override
  public String operation() {
    log.info("MessageDecorator 실행");
    return "****" + component.operation() + "****";
  }
}

 

프록시 패턴과 데코레이터 패턴의 차이


둘 다 프록시를 사용하는 방식이지만, 의도에 따라 프록시패턴과 데코레이터 패턴으로 구분합니다.

  • 프록시 패턴
    • 접근 제어
    • 구성 관계(Composition)
    • 프록시 객체와 실제 객체의 관계가 컴파일 타임에 정해진다.
  • 데코레이터 패턴
    • 부가기능 추가
    • 집합 관계(Aggregation)
    • 프록시 객체와 실제 객체의 관계가 런타임에 정해진다.

 

구체 클래스 기반 프록시(상속)


@Slf4j
public class ConcreteLogic {

  public String operation() {
    log.info("ConcreteLogic 실행");
    return "data";
  }

}
@Slf4j
public class TimeProxy extends ConcreteLogic {

  private ConcreteLogic concreteLogic;

  public TimeProxy(ConcreteLogic concreteLogic) {
    this.concreteLogic = concreteLogic;
  }

  @Override
  public String operation() {
    log.info("TimeProxy 실행");

    long start = System.currentTimeMillis();
    var result = concreteLogic.operation();
    long end = System.currentTimeMillis();
    long resultTime = end - start;

    log.info("TimeProxy 종료, Running time={}", resultTime);
    return result;
  }

}

 

위의 예제에 부모 클래스에 final 키워드가 붙은 필드가 추가된다면 부모 클래스의 생성자를 호출해야 하기 때문에 파라미터를 넣어서 super(..) 호출이 강제되게 됩니다. 또한 클래스나 메서드에 final 키워드가 붙는 경우 상속이 제한되기 때문에 클래스 기반의 프록시를 사용할 수 없습니다.

 

인터페이스 기반 프록시(합성)


public interface Subject {

  String operation();

}
@Slf4j
public class RealSubject implements Subject {

  @Override
  public String operation() {
    log.info("실제 객체 호출");
    sleep(1000);
    return "data";
  }

  private void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

}
@Slf4j
public class CacheProxy implements Subject{

  private Subject subject;
  private String cache;

  public CacheProxy(Subject subject) {
    this.subject = subject;
  }

  @Override
  public String operation() {
    log.info("프록시 객체 호출");
    if(cache == null) {
      return cache = subject.operation();
    }
    return cache;
  }

}

인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭고, 역할과 구현을 명확하게 나눌 수 있다는 점에서 장점이 있습니다.

 

하지만 인터페이스가 필요하다는 단점이 있습니다. 인터페이스의 도입은 구현이 변경될 가능성이 있을 때 효과적인데, 구현이 변경될 가능성이 없는 코드에 항상 인터페이스를 도입하는 것은 번거로울 수 있습니다.

 

Reference


'CS' 카테고리의 다른 글

[Network] 웹소켓(Websocket)  (0) 2022.10.09
[OS] 데드락의 탐지와 해결방법  (1) 2022.10.03
[Design Pattern] Facade 패턴을 통한 SRP원칙 준수  (0) 2022.08.22
[Design Pattern] Singleton Pattern  (0) 2022.08.22
[OS] Scheduler  (0) 2022.08.22

문제 상황

기존 테스트의 개수가 수백 개를 넘어가다 보니 테스트 속도를 조금이나마 개선해 보고자 테스트를 병렬로 수행시켜 보았습니다. 하지만, 단일 스레드에서는 잘 동작하던 테스트가 병렬로 수행하니 몇몇 깨지는 테스트들이 존재했습니다.

 

그중 웹소켓의 메시지 형식이 유효하지 않을 경우 예외 처리 메서드가 호출되는지 verify() 메서드를 통해 검증하는 테스트가 있었습니다. @Parameterized를 통해 테스트를 수행하였고, 각 테스트 인자당 1번씩만 호출되기를 기대했지만 스레드의 개수(4)만큼 호출되어 테스트가 실패하고 있었습니다.

 

[채팅 메시지를 받는 핸들러] 

  @MessageMapping("/room/{id}")
  @SendTo("/chat/room/{id}")
  public ChatPublishMessage send(
      @DestinationVariable
      long id,
      @Valid
      ChatMessage chatMessage
  ) {
    return chatService.sendMessage(id, chatMessage);
  }
@Getter
public class ChatMessage {

  @Positive
  private final long userId;

  @NotBlank
  @Length(min = 1, max = 2000)
  private final String content;

}

 

[예외처리 메서드]

@MessageExceptionHandler(RuntimeException.class)
public void handleException(RuntimeException e) {
  log.info(e.getMessage());
}

 

[테스트 코드]

content의 길이가 범위(1~2000)를 벗어 난 경우 예외처리 메서드(handleException())를 호출하는지에 verify 메서드를 통해 검증하는 방식으로 테스트를 구현하였습니다. websocket에 대한 테스트기에 따로 격리 환경을 구성하기 어려워 통합 테스트로 진행하였으며, 에러 핸들러가 있는 객체를 @SpyBean으로 등록하였습니다.

 

SpringBootTest의 경우 매번 applicationContext를 새로 생성하게 되면 리소스가 낭비되기 때문에 context caching을 통해 application콘텍스트를 재사용하게 됩니다. 이때 재사용 여부를 결정하는 데 있어 어떤 bean을 Mock으로 처리했느냐가 영향을 미치게 됩니다. 그래서 @MockBean이나 @SpyBean을 사용할 경우 동일한 test context를 사용하게 되는데, 병렬로 수행할 경우 test context를 사용하는 스레드들이 동일한 객체의 메서드를 호출할 것입니다.

@SpringBootTest(webEnvironment = DEFINED_PORT)
class ChatWebSocketControllerTest {

  @SpyBean
  ChatWebSocketController chatWebSocketController;
  
  ...
  
  @Nested
  @DisplayName("send 메서드는")
  class DescribeSend {
  
    @Nested
    @DisplayName("content길이가 범위를 벗어나면")
    class ContextWithOutOfRangeContentLength {

      @ParameterizedTest
      @ArgumentsSource(ContentSourceOutOfRange.class)
      @DisplayName("에러 핸들러를 호출한다.")
      void ItCallExceptionHandler(String content) throws InterruptedException {
        //given
        long roomId = 1L;

        //when
        String pubUrl = MessageFormat.format("/message/room/{0}", roomId);
        session.send(pubUrl, new ChatSendMessage(1L, content));

        //then
        sleep(1000);
        verify(chatWebSocketController).handleException(any(RuntimeException.class));
      }
    }
  }

 

 

단일 스레드 환경에서는 잘 동작하는 것을 확인할 수 있었는데, 스레드를 4개를 두고 병렬로 수행하였더니 테스트당 1번만 호출되어야 하는 예외처리 메서드가 4번이나 호출되는 것을 확인할 수 있었습니다. 메서드의 호출 횟수를 모든 스레드가 공유하고 있는 것 같다는 생각에 verify() 메서드의 내부 구현을 확인하였습니다.

 

Spring 공식문서에서의 병렬 테스트에 대한 언급

https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#testcontext-parallel-test-execution

@MockBean, @SpyBean을 사용하는 경우에는 테스트를 병렬로 수행하지 말라라고 언급하고 있습니다.

 

Mockito.verify()의 구현

public static <T> T verify(T mock) {
    return MOCKITO_CORE.verify(mock, times(1));
}

 

verify메서드를 들여다보기 전에 인자로 전달된 times() 메서드를 확인해 보았습니다.

 

  ...
  public static VerificationMode times(int wantedNumberOfInvocations) {
    return VerificationModeFactory.times(wantedNumberOfInvocations);
  }
  ...
  ...
  public static Times times(int wantedNumberOfInvocations) {
    return new Times(wantedNumberOfInvocations);
  }
  ...
public class Times implements VerificationInOrderMode, VerificationMode {

    final int wantedCount;

    public Times(int wantedNumberOfInvocations) {
        if (wantedNumberOfInvocations < 0) {
            throw new MockitoException("Negative value is not allowed here");
        }
        this.wantedCount = wantedNumberOfInvocations;
    }
    
    @Override
    public void verify(VerificationData data) {
        List<Invocation> invocations = data.getAllInvocations();
        MatchableInvocation wanted = data.getTarget();

        if (wantedCount > 0) {
            checkMissingInvocation(data.getAllInvocations(), data.getTarget());
        }
        checkNumberOfInvocations(invocations, wanted, wantedCount);
    }
    ...

times() 메서드는 Times 객체를 팩토리를 통해 생성하여 반환합니다. 이때 times의 인자로 전달된 숫자는 wantedCount 필드 값이 됩니다. 이때 반환된 times() 객체의 verify() 메서드를 호출하여 메서드의 실제 호출 횟수와 기대하는 호출 횟수를 비교하게 되는데요, verify 메서드는 모킹 된 객체의 함수 호출 정보를 인자를 통해 전달받습니다.

 

@Override
public void verify(VerificationData data) {
    List<Invocation> invocations = data.getAllInvocations();
    MatchableInvocation wanted = data.getTarget();

    if (wantedCount > 0) {
        checkMissingInvocation(data.getAllInvocations(), data.getTarget());
    }
    checkNumberOfInvocations(invocations, wanted, wantedCount);
}

 

이때 전달하는 VerificationData는 InvocationContainer로부터 메서드의 호출 정보를 가져옵니다. Mockito는 CGLib을 통해 Mock 대상으로 지정된 객체들의 프락시를 만들고 해당 객체들이 호출되면 InvocationContainer에 객체의 호출 정보를 저장합니다. 

 

public class InvocationContainerImpl implements InvocationContainer, Serializable {

    ...

    public void setInvocationForPotentialStubbing(MatchableInvocation invocation) {
        registeredInvocations.add(invocation.getInvocation());
        this.invocationForStubbing = invocation;
    }

결국 메서드의 호출 정보는 객체 안에 저장되어 heap 영역에서 관리되기 때문에 모든 스레드가 호출 정보를 공유하게 됩니다.

 

건강한 테스트

Is Mockito thread-safe?
For healthy scenarios Mockito plays nicely with threads. For instance, you can run tests in parallel to speed up the build. Also, you can let multiple threads call methods on a shared mock to test in concurrent conditions. Check out a timeout() feature for testing concurrency.
However Mockito is only thread-safe in healthy tests, that is tests without multiple threads stubbing/verifying a shared mock. Stubbing or verification of a shared mock from different threads is NOT the proper way of testing because it will always lead to intermittent behavior. In general, mutable state + assertions in multi-threaded environment lead to random results. If you do stub/verify a shared mock across threads you will face occasional exceptions like: WrongTypeOfReturnValue, etc.

 

Mockito에서는 stubbing이나 verification에 대해서는 thread-safe하지 않다고 이야기합니다. 생각해 보면 스프링 빈은 여러 스레드에 걸쳐 공유되므로, 멀티스레드 환경에서 Websocket과 같은 비동기적 요청을 특정 함수의 호출 여부로 검증하는 것은 현재 요청으로 인한 호출이 아닐 가능성이 있기 때문에 바람직하지 않은 방식으로 보입니다. 

 함수의 호출을 검증하는 테스트가 과연 좋은 테스트일지 고민해보면서, 여태껏 테스트 커버리지를 올리기 위한 테스트 작성에 급급하지 않았나 하는 생각이 듭니다. 

+ Recent posts