문제 상황

팀원이 spring filter가 이상하다고 도움을 요청했다. 확인해보니 swagger에서 실행한 API 요청이 filterChain을 2번 타는 상황이었다. 더 자세히 살펴보니 AccessDeniedHandler를 거친 요청이 다시 필터를 거쳐서 AuthenticationEntryPoint로 도달하는 현상이다. 403이 발생해야 하는 요청이 401이 발생한 것이다.

그래서 내가 진행하던 프로젝트에서 실행해보니 이런 문제는 발생하지 않았다. 팀원의 spring boot 버전은 3.x이고, 내가 테스트해본 환경은 spring boot 2.x 버전이다.

뿐만 아니라 custom filter를 추가하고, 해당 필터에서 exception을 던져도 다시 그 filter를 호출한다. doFilter를 하지 않았는데 어떻게 다시 요청이 들어오지?

 

원인 파악

디버그 모드로 request의 uri를 찍어보면 처음은 요청한 url(/users)이 잘 나오는데, 두번째에서는 에러 url(/error)이 나타난다. 처음 요청에서 문제가 발생했고, 이를 해결하기 위해 /error로 rediect 한 것으로 보인다. 즉 실제로 요청이 2번 있었기 때문에 filter chain을 2번 탄 것이다.

 

그럼 왜 redirect를 하고 있을까?

HttpServletResponse  sendError() 함수를 사용하기 때문에 발생했다. 문제가 발생한 코드를 살펴보자.

@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
    @Throws(IOException::class, ServletException::class)
    override fun handle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        accessDeniedException: AccessDeniedException
    ) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN)
    }
}

 

AccessDeniedHandler를 custom하게 등록했는데, 이 때 sendError 함수를 사용했다. 해당 함수의 주석을 보면 다음과 같이 나와있다.

sendError 코드의 주석 내용

Sends an error response to the client using the specified status code and clears the output buffer. The server defaults to creating the response to look like an HTML-formatted server error page containing the specified message, setting the content type to "text/ html", leaving cookies and other headers unmodified. If an error-page declaration has been made for the web application corresponding to the status code passed in, it will be served back in preference to the suggested msg parameter. If the response has already been committed, this method throws an IllegalStateException. After using this method, the response should be considered to be committed and should not be written to.

 

또한 Spring 공식 문서에도 잘 나와있는데, /error 경로를 맵핑하여 JSON 응답 혹은 WhiteLabel 에러 페이지를 보여준다고 설명한다.

 

즉 Spring에서는 HttpServletContainer의 에러를 처리하기 위해 /error 경로를 활용하고 있고, sendError() 함수 호출 시 /error 경로로 redirect 하여 등록된 WhiteLabel 페이지를 보여주거나 error json을 내려준다.

 

그럼 왜 spring boot 2.x에서는 잘 됐을까?

stackoverflow를 확인해보면, spring boot 2.x부터 error endpoint가 bypass 되던 설정을 제거하고, secure 설정을 default로 넣는다고 되어있다.

Spring Boot 2.0 doesn’t deviate too much from Spring Security’s defaults, as a result of which some of the endpoints that bypassed Spring Security in Spring Boot 1.5 are now secure by default. These include the error endpoint and paths to static resources such as /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico. If you want to open these up, you need to explicitly configure that.

 

여기서 알 수 있는 점은, /error 경로에 대해서 spring boot 2 부터는 bypass가 아니기 때문에 filterChain을 거친다는 의미이다.

 

Spring Security 5.7 공식문서를 확인해보면, DispatcherType.ERROR 요청은 AuthorizationFilter를 타지 않는다고 한다.

By default, the AuthorizationFilter does not apply to DispatcherType.ERROR and DispatcherType.ASYNC. We can configure Spring Security to apply the authorization rules to all dispatcher types by using the shouldFilterAllDispatcherTypes method

 

그러나 Spring Security 6 부터는 모든 Dispatch에 대해서는 AuthorizationFilter를 타게 변경되었다. [공식문서]

All Dispatches Are Authorized

The AuthorizationFilter runs not just on every request, but on every dispatch. This means that the REQUEST dispatch needs authorization, but also FORWARDs, ERRORs, and INCLUDEs.

 

추가로 filter chain이 2번 탈 수 있기 때문에 주의하라는 내용도 있다.

then Boot will dispatch it to the ERROR dispatch.

In that case, authorization also happens twice; once for authorizing /endpoint and once for dispatching the error.

For that reason, you may want to permit all ERROR dispatches.

 

정리하자면 Authorization에서 해당 요청에 대한 권한 처리를 수행하는데, DispatcherType.ERROR 인 경우 5.7에서는 AuthorizationFilter를 타지 않고, 6.0 부터는 AuthorizationFilter를 타게 되므로 /error 에 대한 권한 처리가 필요하다.

 

결론

  1. request에서 에러가 발생하면 whitelabel 페이지를 보여주거나, error json을 내려주는 작업을 위해서 /error 로 요청을 다시 보낸다. (DispatcherType.ERROR)
  2. Spring Boot 2.x 부터 error endpoint에 대해서 bypass 설정은 없어지고, secure가 기본 설정이 되었다
  3. Spring Security 5.7.9에서는 DispatcherType.ERROR 는 AuthorizationFilter 를 거치지 않기 때문에 filter를 타긴 하지만 권한 에러는 발생하지 않는다
  4. Spring Security 6.3.2 에서는 모든 Dispatcher에 대해서 AuthorizationFilter 를 타기 때문에 /error 요청에 대한 권한 에러가 발생하게 된다
    • 에러발생 케이스 흐름
    • AccessDeniedHandler 에서 403 후 sendError → /error → AuthenticationEntryPoint에서 401 발생 (인증정보가 없기 때문)

 

해결방법

1. DispatcherType.ERROR 를 통과시키기

/error 로 요청이 갔을 때 필터를 타는걸 막을 순 없으므로, 해당 DispatcherType을 permitAll 해준다.

이렇게 추가해준다면, 결국 filterChain은 2번 호출되게 된다.

http {
    authorizeHttpRequests {
        authorize(DispatcherTypeRequestMatcher(DispatcherType.FORWARD), permitAll)
        authorize(DispatcherTypeRequestMatcher(DispatcherType.ERROR), permitAll)
        authorize("/endpoint", permitAll)
        authorize(anyRequest, denyAll)
    }
}

 

 

2. sendError() 를 쓰지 않기

ExceptionFilter, AuthenticationEntryPoint , AccessDeniedHandler 등 에러를 처리하는 곳에서 sendError 함수를 사용하지 않는다.

filter를 2번 탔을 때 filter를 거치면서 발생하는 오버헤드가 존재하기 때문에, /error 로 에러처리를 하지 않는 이 방법을 추천한다.

  • AS-IS
    class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
        @Throws(IOException::class)
        override fun commence(
            request: HttpServletRequest,
            response: HttpServletResponse,
            authException: AuthenticationException
        ) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
        }
    }
  • TO-BE
    class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
        @Throws(IOException::class)
        override fun commence(
            request: HttpServletRequest,
            response: HttpServletResponse,
            authException: AuthenticationException
        ) {
            response.status = HttpServletResponse.SC_UNAUTHORIZED
        }
    }

 

비고

FilterChain에서 AuthorizationFilter 가 안보임

spring security 5.7에서는 가능한 현상이다.

만약 security 설정에서 authorizeRequests 를 사용했다면, FilterChainProxy 가 맨 마지막에 생성되어 있을 것이다.

spring 공식문서에서는 AuthorizationFilter를 사용하고 싶다면, authorizeRequests → authorizeHttpRequest 로 바꾸어서 사용하라고 권장한다. [공식문서]

 

그럼 바꿔서 얻는 이점은 무엇일까?

spring은 이렇게 설명하고 있습니다

1. Uses the simplified AuthorizationManager API instead of metadata sources, config attributes, decision managers, and voters. This simplifies reuse and customization.
2. Delays Authentication lookup. Instead of the authentication needing to be looked up for every request, it will only look it up in requests where an authorization decision requires authentication.

3. Bean-based configuration support.

 

migration을 하려면 [공식문서]를 참고하면 된다.

 

관련 문서

'Spring' 카테고리의 다른 글

PermitAll의 함정  (0) 2024.08.16
Spring @RequestParam 주의할 점  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

🤨 문제 상황

Spring Security 5.6.2 에서 permitAll()로 권한 설정한 api를 요청했을 때 403 exception이 발생했다. permitAll인데 왜 403이 발생하지? 라는 생각이 들어 자세히 찾아보기로 했다.

@Configuration
@EnableWebSecurity
class WebSecurity {
    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .cors().and()
            .csrf().disable()
            .anonymous().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatcher(HttpMethod.GET, "/v{\\\\d+}/users**").permitAll()

        return http.build()
    }
}

 

위와 같이 security 설정을 하고 Debug 모드로 실행해보니, controller까지 요청이 들어오지 않았다.

→ security에서 403을 내려주는 것으로 보인다.

 

오잉? permitAll()은 해당 api의 요청을 모두 허용하는 것이 아닌가?

🧐 원인 분석

permitAll의 함수를 따라들어가보면,

public ExpressionInterceptUrlRegistry permitAll() {
    return access(permitAll);
}

ExpressionUrlAuthorizationConfigurer 클래스의 함수라는 것을 확인할 수 있다.

 

그런데 이름을 보다 보면, Authorization?? 인가를 확인하는 클래스네? 인증이 아닌 인가를 처리하는 곳에서 작업이 일어나는구나!

그럼 인증이 안돼서, 즉 SecurityContextHoder에 인증정보가 없어서 인증 안된 사용자라고 판단하는것인가? 라는 가설을 세울 수 있다.

 

FilterChain에서 발생한 에러는 ExceptionTranslation에서 처리할 것이다. 그러니 ExceptionTranslationFilter에서 어떤 에러가 발생하는지 확인하면서 올라가며 가설을 검증하는 것이 좋아보인다..

  • 참고
ExceptionTranslatorFilter 주석
Handles any AccessDeniedException and AuthenticationException thrown within the filter chain. This filter is necessary because it provides the bridge between Java exceptions and HTTP responses. It is solely concerned with maintaining the user interface. This filter does not do any actual security enforcement. If an AuthenticationException is detected, the filter will launch the authenticationEntryPoint. This allows common handling of authentication failures originating from any subclass of org. springframework. security. access. intercept. AbstractSecurityInterceptor.
// AbstractSecurityInterceptor.java 199 lines
...
if (SecurityContextHolder.getContext().getAuthentication() == null) {
    credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
    "An Authentication object was not found in the SecurityContext"), object, attributes);
}
...

 

ExceptionTranslationFilter 부터 쭉 타고 올라가다 보면, AbstractSecurityInterceptor 에서 위 코드와 같이 SecurityContextHolder에서 authentication을 찾고, 없으면 AuthenticationCredentialsNotFoundException 을 발생시킨다.

 

이 에러를 ExceptionTranslationFilter에서 try-catch로 잡아서 처리한다. 이 때, 아래 코드를 보면 되는데

// ExceptionTranslationFilter.java 178 lines
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException exception) throws ServletException, IOException {
    this.logger.trace("Sending to authentication entry point since authentication failed", exception);
    sendStartAuthentication(request, response, chain, exception);
}

 

sendStartAuthentication 으로 보내게 된다. 그럼 해당 함수를 보자.

// ExceptionTranslationFilter.java 208 lines
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException reason) throws ServletException, IOException {
    // SEC-112: Clear the SecurityContextHolder's Authentication, as the
    // existing Authentication is no longer considered valid
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    SecurityContextHolder.setContext(context);
    this.requestCache.saveRequest(request, response);
    this.authenticationEntryPoint.commence(request, response, reason);
}

 

여기서 authenticationEntryPoint로 보내는 것을 볼 수 있다. 즉 기본값인 Http403ForbiddenEntryPoint 로 보내져서 403이 발생하는 것이다.

 

즉, 어떤 인증도 수행하지 않아서 SecurityContext에는 인증정보가 없는 것이고, 그래서 authenticationEntryPoint로 보내져 403에러가 발생했던 것이다.

 

그럼 인증이 필요없는 API는 어떻게 처리하나?

 

그럴줄 알고, Spring Security에서는 AnonymousAuthenticationFilter를 만들어 두었다. 재밌는건, AnonymousAuthenticationFilter에서 isAuthenticated()는 true가 된다. 다른 말로는 SecurityContextHolder의 authentication에 익명사용자라는 AnonymousAuthenticationToken 을 설정해준다.

어떻게 보면 당연한 이야기인게, 인증하지 않은 사용자를 인가처리(permitAll 과 같은 작업) 하기 위해 익명사용자라는 것으로 감싸는 것이기 때문에, 인증이 되었다고 보지 않으면 위와 같은 403 에러가 발생할 것이다.

 

그럼 발생했던 문제는 어떻게 해결할 수 있나?

 

🖋️ 해결 방법

해결 방법은 단순하다.

.anonymous().disable() // 제거

anonymous를 disable하지 않으면 된다.

 

❓ 궁금한 점

그럼 authenticated()는 anonymous 사용자도 통과되나? isAuthenticated()가 true니까 통과될 것 같은데?
→ 실제로 해보면 안된다. 사실 되면 이상한거다.(spring이 그렇게 못 만들었을리 없다)

위 내용을 읽으면 눈치 챘겠지만, authenticated()는 인가의 역할이다. AnonymousAuthenticationToken 의 isAuthenticated()는 인증의 역할이다. 2개의 역할은 다르기 때문에 다른 시각에서 봐야한다.

인가는 AuthorizationFilter 에서 처리하는데, 코드를 한번 보자.

// AuthorizationFilter.java 54 lines
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    this.authorizationManager.verify(this::getAuthentication, request);
    filterChain.doFilter(request, response);
}


인가는 authorizationManager가 관리하고 있고, verify함수에서 처리한다.

// AuthorizationManager.java 41 lines
default void verify(Supplier<Authentication> authentication, T object) {
    AuthorizationDecision decision = check(authentication, object);
    if (decision != null && !decision.isGranted()) {
        throw new AccessDeniedException("Access Denied");
    }
}


유저가 authenticated 되어있는지 판단하는 manger는 AuthenticatedAuthorizationManager 이다. AuthorizationManger의 주석에는 다음과 같이 설명한다.
| An AuthorizationManager that determines if the current user is authenticated.

// AuthenticationAuthorizationManager.java 53 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorizationDecision(granted);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && isNotAnonymous(authentication) && authentication.isAuthenticated();
}

 

위 코드를 보면, anonymous일 때는 isGanted 가 false가 되는 것을 확인할 수 있다. 이렇게 인가작업인 authenticated()는 anonymous를 통과하지 못하도록 막는다.

 

잠시만, 그럼 permitAll에서도 막히는거 아니야??

 

Authority(hasRole, permitAll 등)을 판단하는 manager는 AuthorityAuthorizationManager 이다. 두 매니저가 다르기 때문에 로직도 다르다.

// AuthorityAuthorizationManager.java 125 lines
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorityAuthorizationDecision(granted, this.authorities);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && authentication.isAuthenticated() && isAuthorized(authentication);
}

 

여기서는 authentication의 isAuthenticated() 를 확인하기 때문에 anonymous user는 통과할 수 있는 것이다.

 

또 잠시만, 그럼 hasRole() 설정에서 anonymousUser는 ExceptionTranslationFilterhandleAuthenticationException 로 가는게 아니고 handleAccessDeniedException로 처리되겠네? AuthorizationManager에서 걸리면 AccessDenied 아니여?

 

당연히 아니다. (Spring이 그렇게 멍청하지 않다니까?)

아래 코드를 봐보자.

// ExceptionTranslationFilter.java 184 lines
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
    if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
        if (logger.isTraceEnabled()) {
            logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
                authentication), exception);
        }
        sendStartAuthentication(request, response, chain,
            new InsufficientAuthenticationException(
                this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                    "Full authentication is required to access this resource")));
        }
        else {
            if (logger.isTraceEnabled()) {
                logger.trace(
                    LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
                        exception);
            }
        this.accessDeniedHandler.handle(request, response, exception);
    }
}

 

코드를 유심히 보면, anonymous일 때 sendStartAuthentication() 를 호출하는 것을 확인할 수 있다. 실제로 테스트해보면 AuthenticationException으로 처리되고, entrypoint를 설정하면 401로 내려간다.

 

  • 유저가 로그인하지 않았을 때는 403이 아닌, 401을 내려주고 싶은데 어떻게 해?
    → 위에서 봤듯이, filterchain에서 AuthenticationException이 발생하고, 이를 잡아서 authenticationEntryPoint를 호출하는 것을 보았다. default값인 Http403ForbiddenEntryPoint 대신에 HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) 으로 설정해주면 된다.
    http
        .exceptionHandling().authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
     

 

📒 정리

  1. webSecurity의 permitAll, authenticated, hasRole 등은 인가 관련 설정이다.
  2. 인증하지 않은 사용자도 해당 인가 설정을 적용하기 위해 spring security는 Anonymous 라는 개념을 사용한다.
  3. AuthenticationEntryPoint로 인증, AccessDeniedHandler로 인가 Exception을 custom하게 처리할 수 있다.

spring은 설정이 간편하지만, 그만큼 설정에 따라 많은것이 바뀐다. 그래서 잘 알고 쓰는게 확실히 중요하다고 느낀다.

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
Spring @RequestParam 주의할 점  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

새로운 프로젝트를 받으면서, 레거시 코드로 인해 발생한 간단한 해프닝을 공유하려고 한다. 레거시 코드라 PUT method에서 RequestParam으로 데이터를 받는 케이스가 있었다..!

문제 상황

  • UDC에서 POST , PUT method에서 body로 보내지 않고, RequestParam 으로 데이터를 보냄
  • 프론트 요청으로 queryParam에 Boolean? 값을 추가함
    @PutMapping
    fun update(
    	@RequestParam(required = false) name: String?,
    	@RequestParam(required = false) isEnabled: Boolean?,
    )
    
  • 프론트에서 “null” 을 넣어서 보내니 400에러가 발생한다고 문의가 들어옴. 값을 넣지 않으면 정상 동작(null로 들어감)
  • 뿐만 아니라 string value에 null을 집어 넣으면 "null" 로 들어가서 db까지 null string으로 저장되는 문제 발생

원인 파악

  1. string value (”null”)을 boolean으로 converting하지 못하는 것으로 예상
    • spring의 @RequestParam 에서 사용하는 converter 확인
      • StringToBooleanConverter 코드
        final class StringToBooleanConverter implements Converter<String, Boolean> {
        
            private static final Set<String> trueValues = new HashSet<>(8);
        
            private static final Set<String> falseValues = new HashSet<>(8);
        
            static {
                trueValues.add("true");
                trueValues.add("on");
                trueValues.add("yes");
                trueValues.add("1");
        
                falseValues.add("false");
                falseValues.add("off");
                falseValues.add("no");
                falseValues.add("0");
            }
        
        
            @Override
            @Nullable
            public Boolean convert(String source) {
                String value = source.trim();
                if (value.isEmpty()) {
                    return null;
                }
                value = value.toLowerCase();
                if (trueValues.contains(value)) {
                    return Boolean.TRUE;
                }
                else if (falseValues.contains(value)) {
                    return Boolean.FALSE;
                }
                else {
                    throw new IllegalArgumentException("Invalid boolean value '" + source + "'");
                }
            }
        
        }
    • 코드를 보면, string "null" 값에 대한 처리는 없음
  2. string의 경우에는 별도의 convert 없이 받는 데이터 그대로 string으로 넘겨줌
  3. @RequestBody 의 경우 jackson converter로 json 객체를 parsing하고, json 객체에는 null과 "null" 을 구분할 수 있기 때문에 큰 문제가 없었음

해결 방법

  1. 별도의 converter 등록하기
    위 converter에서 null string 체크 로직을 추가한 custom converter를 등록하면 된다. 그러나 requestParam은 url에서 파싱한 string 값이고, 이미 요청할 때 string 값을 넣은 것이기 때문에 null 변환이 있는게 더 어색해 보인다.
  2. RequestParam → RequestBody 변환하기
    PUT 요청에서는 requestBody를 쓰는게 일반적이기 때문에 더 자연스러워 보인다.

 

레거시 코드라서 볼 수 있었던 일종의 해프닝이다. 이 계기로 여러 converter들의 코드를 확인해보고, query string을 파싱방법을 리마인드 할 수 있어서 재밌었다.

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
PermitAll의 함정  (0) 2024.08.16
테스트 환경 설정하기  (1) 2024.08.08

어느날, 회사에서 테스트코드를 만들고 있었다. 테스트에서는 DB, Kafka 등의 모든 외부 연결은 mocking하여 사용하고 있었다. 그러나 DB에서 json 타입의 필드를 저장한다던가, converter를 활용하여 DB에 저장되는 값을 변경하는 등의 작업이 많아지면서 외부연동, 특히 DB까지의 테스트가 필요하다고 느끼고 있었다. 그래서 DB까지 연동하여 테스트하는 방법과 연동하는 방법을 찾아보았고 이를 정리해보았다.

 

H2 Database

DB 테스트를 한다고 했을 때, spring에서는 가장 쉽게 도입할 수 있는 것이 h2 database이다.

  • h2 database 설정하기
    • 테스트 어플리케이션 설정
      application-test.yaml 설정에서 datasource에 h2로 설정하고, driver를 h2.Driver 로 변경하면 h2 database를 사용할 수 있다.
      spring:
        datasource:
          url: jdbc:h2:mem:mata;MODE=MySQL;DB_CLOSE_DELAY=-1
          driver-class-name: org.h2.Driver
      ...
      
    • flyway 데이터 / 초기 데이터
      spring boot는 기본적으로 CLASSPATH 에 존재하는 schema.sql , data.sql 파일을 읽어서 초기 데이터를 세팅한다.
      spring.sql.init.mode=always
      
      위와 같은 설정이 필요하지만, h2에서는 기본값이 always 다.
      DROP TABLE IF EXISTS `user`;
      
      CREATE TABLE `user`
      (
          `id` int(11) NOT NULL AUTO_INCREMENT,
      ...
      )
      물론 flyway 설정을 추가해서 초기 데이터 작업을 할 수 있다. 초기 데이터를 넣어서 테스트하는 방법은 여러가지가 있으니 [관련자료] 에서 확인할 수 있다.

장점

    • 설정하기 쉽다. 초기 데이터 설정도 하지 않을것이라면 test용 spring 설정 파일에 datasource만 h2로 넣으면 된다.
    • in-memory database라서 추가로 고려해야할 점이 많지 않다. (docker 컨테이너를 띄우려면 docker engine이 실행되어 있어야하고, port도 설정해야하고..)
    • 또한 h2 는 다양한 데이터베이스와의 호환성을 제공한다. [공식문서]

 

단점

  • in-memory라서 많은 데이터를 넣으면 성능에 큰 영향을 미칠 수 있다. 테스트 환경에 따라 테스트가 정상 실행되지 않을 수도 있는 것이다.(경험상 이런적은 없긴 하다.)
  • mysql과 100% 호환되지 않는다.
    • 🚨 실제로 mysql과 collation이 달라서, 데이터 검색 시 대소문자 구분으로 인해 테스트는 실패하고 로컬서버에서는 성공하는 문제가 있었다.
In MySQL text columns are case insensitive by default, while in H2 they are case sensitive. However H2 supports case insensitive columns as well. To create the tables with case insensitive texts, append IGNORECASE=TRUE to the database URL (example: jdbc:h2:~/test;IGNORECASE=TRUE).

[공식문서]

 

 

TestContainter

H2 DB와 같이 테스트에서의 환경이 달라 발생하는 문제를 해결하기 위해 나온 오픈소스 프레임워크로, 테스트 환경에서 독립적으로 실행되는 컨테이너를 구축할 수 있다.

장점

  • mysql 서버를 띄우는 것이기 때문에 운영 환경과 동일한 테스트 환경을 구축할 수 있다. mysql 버전 및 변수까지 동일하게 설정 가능하다.
  • container를 띄우는 것이기 때문에 테스트별로 컨테이너를 실행하여 테스트 독립성을 높힐 수 있고, 여러 datasource 환경에서도 적용이 가능하다.
  • database 뿐만 아니라 MQ 등 다양한 기능들이 존재한다. (컨테이너만 띄우면 됨)
  • h2 보다는 추가 설정이 필요하지만, 단순한 설정으로도 실행이 가능하다.
  • mysql 서버를 띄우면서 초기 데이터를 삽입하여 테스트를 더 편하게 할 수 있다.

단점

  • 실행하는 머신에서 docker engine이 존재해야한다.
    • github action 시 self-hosted-runner 를 사용하면, 해당 머신에 docker engine이 설치돼 있어야 한다.
    • 로컬에서 실행하려면 docker engine을 켜야한다.

사용 방법

1. build.gradle에 의존성 추가

testImplementation platform('org.testcontainers:testcontainers-bom:1.19.1') // 선언하면 testcontainer 모듈들의 버전을 통합 관리해줌
testImplementation "org.testcontainers:testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:mysql"

 

2. 테스트 어플리케이션 설정 추가

 

datasource와 driver를 원하는 database에 맞게 변경해준다.

mysql 외에 다른 DB는 [공식문서] 를 참고하면 된다.

spring:
  datasource:
    url: jdbc:tc:mysql:8.0.26:///user&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...

 

3. 컨테이서 실행 시 초기 데이터 설정 추가

 

TC_INITSRIPT 에 contianer가 실행될 때 실행할 sql 파일을 넣어주면, MySQL 컨테이너가 실행되면서 해당 SQL 파일이 실행된다.

(mysql에서는 spring.sql.init.mode 설정 기본값이 never 이다. 따라서 schema.sql 파일로 인해 초기 데이터가 세팅되는 일은 설정을 추가하지 않는 한 없다. 따라서 schema.sql로 초기 세팅 SQL 파일을 만들었다.)

 

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user`
(
    `id` int(11) NOT NULL AUTO_INCREMENT,
...
)
spring:
  datasource:
    url: jdbc:tc:mysql:8.0.26:///incident?TC_INITSCRIPT=file:src/test/resources/schema.sql&TC_DAEMON=true&autoReconnect=true&serverTimezone=UTC&characterEncoding=utf8&useSSL=false
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
...

 

flyway 설정을 추가해서 테이블을 생성하는 것도 가능하다. 다만 테스트마다 flyway 파일들을 하나씩 실행해야 하므로 테스트 시간이 더 길어진다.

 

참고

  • 테스트마다 새로운 컨테이너를 띄우는 것도 가능하다. [관련 자료]
  • mysql 외에 redis, kafka 등 다양한 컨테이너 설정 예시들이 있으니, 공식문서에서 확인해보면 친절하게 설명해준다. [공식 문서]

여러 테스트 설정

이렇게 끝내려고 했지만, 내가 테스트할 때 설정하는 방법들을 공유하면 좋을 것 같아서, 여러 테스트를 위해 내가 부리는 기교들을 공유하려고 한다.

Integration 테스트하기

integration 테스트라고 하니 논란이 있을 수 있지만, 여기서 얘기하는 것은 spring application을 실행하고 이를 테스트하는 것을 뜻한다. 모든 테스트마다 @ActiveProfiles("test") , @SpringBootTest 를 붙히는건 귀찮으니, @IntegrationTest 라는 어노테이션을 만들고, spring을 띄워서 테스트할거야 라는 의미를 줄 수 있도록 만들었다.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
annotation class IntegrationTest

Redis 설정하기

RDB 뿐만 아니라, Redis 또한 많이 사용할 것이다. 마찬가지로 나도 session 방식 로그인을 위해 사용하고 있다. 이를 테스트하기 위해서는 redis pod를 띄워야하는데, mysql 처럼 application 설정 만으로 띄우기는 어렵다.

따라서 redis container를 정적 변수로 만들고, application context에 추가하여 redis.port 와 같은 설정값에 잘 적용되도록 설정하였다.

또한 redis 설정을 @IntegrationTest 어노테이션에 추가하여 해당 어노테이션 사용 시 자동으로 redis가 실행되도록 하였다.

 

class TestRedisConfiguration : ApplicationContextInitializer<ConfigurableApplicationContext> {
    companion object {
        val redisContainer = GenericContainer<Nothing>("redis:alpine")
            .apply {
                withExposedPorts(6379)
                withReuse(true)
            }
    }

    override fun initialize(applicationContext: ConfigurableApplicationContext) {
        redisContainer.start()

        TestPropertyValues.of(
            "spring.redis.host=${redisContainer.host}",
            "spring.redis.port=${redisContainer.firstMappedPort}"
        ).applyTo(applicationContext.environment)
    }
}
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@SpringBootTest
@ContextConfiguration(initializers = [TestRedisConfiguration::class])
annotation class IntegrationTest

Mock으로 객체 만들어서 Bean에 넣기

테스트를 하다보면 mock의 필요성을 많이 느끼게 될 것이다. 예를 들면, 외부 API 연동까지 테스트를 해야하나? 이걸로 인해 테스트까지 영향 받는 것이 맞나? 싶은 생각이 들 것이다. 이 때 mocking을 통해 쉽게 해결할 수 있다.

(개인적으로는 외부 연동까지 테스트코드를 통해 테스트 하는 것은 과하다고 생각한다.)

  • mockkBean 사용하기
    Mockito 환경에서 테스트 코드를 짜 본 사람이라면 @MockBean을 사용하면 되는데? 라고 생각할 것이다. 그러나 kotest와 mockk를 사용하는 코틀린 테스트 환경에서는 MockBean을 사용하면 정상 동작하지 않는다. mockk와 mockito가 충돌이 발생하여 함수가 잘 mocking 되지 않는다. 정확히는 mockito의 mockBean을 사용하면, 해당 객체의 설정은 mockito 기능을 사용해야한다.

    두 라이브러리 모두 every 라는 함수를 통해 객체의 함수를 mocking하기 때문에, 각 객체가 어떤 라이브러리에 의해 mocking 되었는지를 찾아서 해당하는 라이브러리의 함수를 사용해야 된다는 의미이다.

    그러나 이를 하나하나 비교하며 다른 기능을 쓰기에는 무리가 있다. 그래서 나는 어떤 사람이 만들어 놓은 mockk를 위한 mockbean을 사용한다.
    mockk에서 정식으로 지원하는 라이브러리는 아니지만, 481 stars 를 받을 정도로 인기있는(?) 라이브러리이다. [Github]
    1. gradle 의존성 추가
      testImplementation("com.ninja-squad:springmockk:3.1.1")
      
    2. @MockkBean 사용
      @IntegrationTest
      @DisplayName("User Service Test")
      class UserServiceTest(
          private val userService: UserService,
          @MockkBean(relaxed = true)
          private val userRepository: UserRepository,
      ) : BehaviorSpec({
      ...
      
    3. mock 데이터 추가 
      every {
          userRepository.save(user)
      } returns user
      
  • @Primary 사용하기
    만약 위 방법이 마음에 들지 않는다면, test 환경에서 사용하기 위한 객체를 만들고 bean에 등록하면 된다. 그리고 @Primary 어노테이션을 활용하여, 해당 객체를 테스트 코드에서 주입받을 수 있도록 설정한다. 
    • 예시 코드
      S3에 upload하는 클래스를 mocking하는 코드이다. 클래스의 각 함수들이 원하는 방식대로 동작하도록 mocking 해 두고 이를 Bean에 등록한다.
    @Configuration
    @Profile("test")
    class MockUploaderConfiguration {
        @Bean
        @Primary
        fun mockPrivateUploader(): PrivateUploader {
            val s3Object = mockk<S3Object>(relaxed = true)
            val uploader = mockk<PrivateUploader>(relaxed = true)
            
            every { uploader.getS3Object(any()) } returns s3Object
    
            return uploader
        }
    }

Multi module에서 테스트 관련 설정 한곳에 몰아넣기

multi module 환경에서는 코드들이 분리되어 있기 때문에 테스트 관련 설정을 각 모듈별로 가지고 있어야 한다. 이런 불편함을 없애기 위해, 나는 해당 설정을 한 곳에서 모아 관리하는 방법을 사용한다.

  1. core 모듈의 build.gradle.kts에서 testJar task 추가
    tasks.register<Jar>("testJar") {
        from(sourceSets["test"].output)
        archiveClassifier.set("test")
    }
    
  2. testArchives 추가 및 testJar task 추가
    configurations {
        create("testArchives") {
            extendsFrom(configurations["testImplementation"])
        }
    }
    
    artifacts {
        add("testArchives", tasks.named("testJar"))
    }
    
  3. API 모듈(사용하는쪽) 에서 test 의존성 추가
    dependencies {
        testImplementation(project(":core", configuration = "testArchives"))
    }

Controller 테스트하기

controller 테스트는 mockMVC를 사용하여 테스트하고, api 요청과 동일한 것처럼 보이지만 filter까지 테스트하지는 않는다. filter까지 테스트하는 방법은 여러 블로그에 잘 나와 있으니 참고하면 좋을 것이다. [관련 블로그]

controller 테스트를 하는 이유는 api로 들어오는 값의 type 혹은 형식검사 등의 기능을 검사하기 위함이라고 생각하기 때문에, service 클래스를 mocking해서 bean에 등록하기도 한다.

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserControllerTest(
    val mockMvc: MockMvc,
) : StringSpec({
    val objectMapper = ObjectMapper()
		
    "User 생성 controller 테스트" {
        val request = CreateUserRequest()
        val result = mockMvc.perform(
            post("/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(
                    objectMapper.writeValueAsString(request)
                )
            )
	
            result.andExpect(status().isOk)
    }
})

 

번외

kotest에서 @Trasnactional이 동작하지 않는 버그를 만났다. 테스트가 완료되어도 데이터가 삭제되지 않은 것인데, 이는 위에서 얘기한 mockbean과 비슷한 이유이다.

@SpringBootTest에 존재하는 SpringExtension 설정은 jupiter의 SpringExtension이기 때문에 kotest를 지원하지 않는 것이다. 따라서 해결 방법은 KoTest의 SpringExtension을 설정하는 것이다. 아래 두 코드 중 원하는 방법을 선택하면 된다.

@ExtendWith(SpringExtension::class)
@Transactional
@SpringBootTest
class Test(): DescribeSpec ({
    extension(SpringExtension)
    ...
})

 

[관련 stackoverflow]

[관련 블로그]

 

마무리

테스트와 관련된 내용을 찾아보다가 재미있는 논쟁이 있어서 공유하면서 마무리하려고 한다.

Spring 테스트에서 @Transactional 을 사용해도 되느냐에 대한 논쟁인데, spring 쪽 거물들이 나와서 논쟁하는 것이 매우 흥미롭다. 한번씩 읽어보면 좋을 것 같다. [향로님 블로그]

 

개인적인 생각으로는... 테스트의 목적이 시스템 안정화와 버그찾기 라는 관점을 가지고 있어서, 간편한 테스트 설정으로 인해 잡지 못한 버그가 운영까지 이어지는 것이 더 위험하다고 생각된다. 결국 트레이드 오프가 있는 것이고, 어떤 것이 더 크리티컬하냐 라고 봤을때는 향로님 관점이 좀 더 공감된다. 일개 주니어의 생각.. 끝

 

 

 

 

'Spring' 카테고리의 다른 글

FilterChain을 2번 호출한다고?  (1) 2024.09.14
PermitAll의 함정  (0) 2024.08.16
Spring @RequestParam 주의할 점  (0) 2024.08.16

+ Recent posts