MilkTea24 / realworld-backend

RealWorld 프로젝트 백엔드 파트를 구현한 프로젝트입니다.
MIT License
0 stars 0 forks source link

docs: 문제 해결 모음 #4

Open MilkTea24 opened 10 months ago

MilkTea24 commented 10 months ago

GradleWorkerMain not found 해결

Issue

1

문제 상황

JaCoCo 도입 중 html 파일이 아닌 xml 파일로 결과를 출력하도록 build.gradle을 바꾸었으나 xml 파일이 생성되지 않았다. 그래서 jacocoTestReport 부분만 따로 실행하니 :test에서 아래와 같은 오류가 발생하였다.

오류: 기본 클래스 worker.org.gradle.process.internal.worker.GradleWorkerMain을(를) 찾거나 로드할 수 없습니다.
원인: java.lang.ClassNotFoundException: worker.org.gradle.process.internal.worker.GradleWorkerMain

해결 방법

결론부터 말하면 Gradle User Home의 경로에 한글이 포함되어서 생긴 문제였다.

Gradle Wrapper도 존재하는 것 확인했고 Build and run using도 IntelliJ IDEA로 변경해주었다. VM 옵션에서 Dfile 인코딩 옵션도 지워보고 annotation processing를 활성화해봐도 해결이 되지 않았다.

그 뒤로 다양한 삽질을 해보던 중 Gradle User Home에 한글이 포함되면 이러한 에러가 발생할 수 있다고 들었다. image

Gradle user home 변경하기

  1. 시스템 환경 변수에서 GRADLE_USER_HOME이란 이름을 가진 변수를 생성하고 값으로 한글이 포함되지 않는 .gradle 경로를 작성하면 된다. 나의 경우 D:.gradle을 값으로 넣어주었다.

  2. IntelliJ를 재시작한다.

  3. 이제 Gradle user home이 환경 변수에 입력한 값으로 변경됨을 IntelliJ에서 확인할 수 있고 build가 성공적으로 실행되어 xml 파일이 생성됨을 확인할 수 있다. image

image

결론

사용자 이름이든 디렉토리 명이든 개발 환경에서 절대 한글을 넣지 말자...

Reference

https://kaydaela.tistory.com/99 https://oysu.tistory.com/87

MilkTea24 commented 10 months ago

단위 테스트에서 Value 어노테이션이 포함된 필드에 값 주입

Issue

2

문제 상황

InitialAuthenticationFilter를 테스트에서 실행하면 다음과 같은 에러가 발생한다.

java.lang.NullPointerException: Cannot invoke "String.getBytes(java.nio.charset.Charset)" because "this.signingKey" is null

InitialAuthenticationFilter에서 signingKey는 @Value를 활용하여 application.properties에서 값을 주입하는데 테스트환경에서 null이 뜨는 것이다. Test 환경에서 @TestPropertiesSource와 @ContextConfiguration을 활용하면 @Value 어노테이션이 달린 필드에 값을 주입할 수 있다고 하는데 나의 경우는 여전히 에러가 발생한다.

해결 방법

@Value 어노테이션이 달린 필드를 final로 설정하여 생성 시 주입하도록 하면 된다. 아주 잘 정리가 된 블로그가 있어 이를 참고하여 해결하였다.

테스트 만을 위해서 Setter를 열어두는 것은 값의 변경 가능성이란 큰 위험을 감수해야 한다. Spring에서 이런 경우를 위해 ReflectionTestUtils라는 유틸리티 클래스를 제공하고 있지만 private 필드의 값을 직접 변경하는 것도 좋은 방법은 아니다.

@Value 어노테이션이 달린 필드를 final로 설정하고 Lombok의 @RequiredArgsConstructor와 함께 사용하면 테스트 시 @Value 어노테이션이 달린 필드의 값을 직접 생성할 때 넣어주면 된다.

이 때 프로젝트의 root 경로에 lombok.config에서 다음과 같은 코드를 작성해야 @RequiredArgsConstructor와 @Value를 함께 사용할 수 있다. 이 코드는 Lombok AnnotationProcessor가 생성자에 필드의 어노테이션을 복사하여 생성자 parameter에도 어노테이션을 추가해주는 옵션이다.

lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value

이제 테스트 시 @Value 어노테이션이 달린 필드의 값을 생성할 때 같이 주입하면 된다.

InitialAuthenticationFilter initialAuthenticationFilter = new InitialAuthenticationFilter(manager, TEST_SIGNING_KEY);

결론

테스트 시 @Value 어노테이션의 필드 값은 생성자 주입으로 해결하자. 이 때 lombok.config에 따로 설정이 필요하다.

Reference

https://www.podo-dev.com/blogs/230 https://www.podo-dev.com/blogs/224

MilkTea24 commented 10 months ago

빈의 순환 참조 문제 해결

Issue

2

문제 상황

이전까지 잘 되었던 SpringBootApplication이 실행이 안되고 다음과 같은 에러가 발생하였다.

ERROR org.springframework.boot.web.embedded.tomcat.TomcatStarter - Error starting Tomcat context. Exception: 
org.springframework.beans.factory.UnsatisfiedDependencyException. Message: Error creating bean with name 
'initialAuthenticationFilter' defined in file [D:\~~~\InitialAuthenticationFilter.class]: Unsatisfied dependency expressed through
 constructor parameter 0: Error creating bean with name 'securityConfig' defined in file [D:\~~~\SecurityConfig.class]: 
Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'initialAuthenticationFilter':
 Requested bean is currently in creation: Is there an unresolvable circular reference?

┌─────┐
|  initialAuthenticationFilter defined in file [D:\~~~\InitialAuthenticationFilter.class]
↑     ↓
|  securityConfig defined in file [D:\~~~\SecurityConfig.class]
└─────┘

현재 InitialAuthenticationFilter를 생성할 때는 AuthenticationManager 빈이 필요하다. 이 AuthenticationManager 빈을 생성하기 위해서는 SecurityConfig에서 생성해야 하는데 SecurityConfig를 생성하기 위해서는 InitialAuthenticaionFilter가 필요하다. 이와 같이 생성할 때 서로 필요하기 때문에 순환 참조 문제가 발생하는 것이다.

image

해결 방법

이 문제를 해결하기 위해 다음과 같은 방법을 고안하였다.

과정 1

InitialAuthenticationFilter는 AuthenticationManager가 필요하고 AuthenticationManager를 생성하기 위한 SecurityConfig는 InitialAuthenticationFIlter가 필요하므로 AuthentciationManager를 다른 Config파일에서 생성하는 방법을 고안하였다. 그래서 AppConfig라는 구성 파일을 새로 만들어서 authenticationManager를 생성하였다.

image

@Configuration  
public class AppConfig {  
    //AuthenticationManager Bean으로 등록  
  @Bean  
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {  
        return authenticationConfiguration.getAuthenticationManager();  
    }

하지만 여전히 다음과 같은 문제가 발생하였다.

┌─────┐
|  initialAuthenticationFilter defined in file [D:\~~~\InitialAuthenticationFilter.class]
↑     ↓
|  authenticationManager defined in class path resource [~~~/AppConfig.class]
↑     ↓
|  securityConfig defined in file [D:\~~~\SecurityConfig.class]
└─────┘

AuthenticationManager에서 SecurityConfig를 의존한다는 것이다. 하지만 AppConfig에서는 어떠한 생성자 주입을 따로 명시하지 않았기 때문에 당황했다.

과정 2

결론은 SecurityConfig에 @EnableWebSecurity 어노테이션을 제거해야 한다.

MilkTea24 commented 10 months ago

No ParameterResolver registered 문제 해결

Issue

2

문제 상황

ArticleRepositoryTest에서 JUnit5를 이용한 테스트 시 아래와 같은 에러가 발생한다.

org.junit.jupiter.api.extension.ParameterResolutionException: No ParameterResolver registered for parameter 
[com.milktea.main.article.repository.ArticleRepository arg0] in constructor [public 
com.milktea.main.article.repository.ArticleRepositoryTest(com.milktea.main.article.repository.ArticleRepository)].

문제 원인

단위 테스트에서 의존성 주입 시 기존 코드를 작성했던 것처럼 Lombok의 RequiredArgsConstructor와 final 접근 제한자를 이용해서 생성자 주입을 하려고 했던 것이 원인이였다. 하지만 InitialAuthenticationFilterTest는 RequiredArgsConstructor와 final을 이용해서 잘 했는데 왜 ArticleRepositoryTest에서는 이러한 에러가 발생하는 것일까? 그 이유는 @DataJpaTest에서 확인할 수 있다.

Parameter Resolver

테스트 코드에서는 Spring이 아닌 JUnit이 의존성 주입을 담당한다. 이 때 Parameter Resolver가 의존성 주입을 담당한다. Baeldung에서 Parameter Resolver를 자세히 확인할 수 있다.

요약하자면,

  1. ParameterResolver를 구현하여 주입할 객체를 생성할 수 있다.
  2. 이 때, supportsParameter 메서드는 테스트의 parameter의 클래스와 Resolver에서 생성하는 클래스와 동일한지 확인하고 resolveParameter는 동일한지 확인이 되면 주입할 클래스를 생성한다.
  3. 클래스나 메서드에 @ExtendWith(구현한 ParameterResolver.class) 어노테이션을 달면 ParameterResolver에 구현한 주입 로직이 자동으로 실행된다.(클래스 scope는 모든 테스트 메서드에, 메서드 scope는 한 테스트 메서드에 적용)

@DataJpaTest를 한번 확인해보자. image @DataJpaTest의 메타 어노테이션 중 @ExtendWith 어노테이션이 있음을 확인할 수 있다. @DataJpaTest는 SpringExtension Parameter Resolver로 의존성을 주입한다는 뜻이다.

SpringExtension 클래스도 직접 들어가서 확인해보자. image ParameterResolver를 구현하는 클래스임을 확인할 수 있다.

SpringExtension 클래스는 resolveParameter를 다음과 같이 구현하고 있다. image

마지막으로 ParameterResolutionDelegate.resolveDependency를 확인하면 다음과 같다. image 여기서 중요한 점은 Autowired 어노테이션을 검색하여 주입할 객체를 확인한다는 것이다. 따라서 @DataJpaTest에서 @ExtendWith(SpringExtension.class)는 주입할 객체를 Autowired 어노테이션을 바탕으로 검색한다. 그래서 이 경우는 @RequiredArgsConstructor와 final을 통한 의존성 주입 대신 Autowired 어노테이션을 사용해야 한다.

문제 해결

@DataJpaTest, @SpringBootTest 등은 메타 어노테이션으로 @ExtendWith(SpringExtension.class)가 존재하므로 생성자 주입은 @Autowired 어노테이션을 사용해야 한다.

결론

여러 프레임워크에서 미리 제공해주는 어노테이션의 용도를 정확히 알아야 이런 일이 발생하지 않을 것 같다..

Reference

https://pinokio0702.tistory.com/189 https://www.baeldung.com/junit-5-parameters

MilkTea24 commented 10 months ago

logger를 사용할 때 발생하는 성능 저하 요인

Issue

8

문제 상황

log와 관련된 내용을 구글링하던 중 log 내부 문자열 concatenation 연산이 오버헤드를 발생한다는 좋은 글을 발견하여 정말 그런지 궁금하여 직접 테스트 해보았다.

Test 환경

Test 1

long startWorstMethodTime = System.nanoTime();  
for (int i = 0; i < TEST_CASES; i++) {  
    log.trace("i = " + i + ", i + 1 = " + (i+1));  
}  
long endWorstMethodTime = System.nanoTime();  
log.debug("일반적인 + 방식을 사용했을 때 걸린 시간은 " + 
formatter.format(endWorstMethodTime - startWorstMethodTime) 
+ "ns");

현재 Log level은 DEBUG이므로 log.trace의 메세지는 당연히 출력되지 않을 것이다. 따라서 어차피 출력되지 않으니 내부 message의 연산에 대해서 크게 신경쓰지 않았던 것 같다.

Test 2

 long startAlternativeMethodTime = System.nanoTime();  
for (int i = 0; i < TEST_CASES; i++) {  
    log.trace("i = {}, i + 1 = {}", i, i+1);  
}  
long endAlternativeMethodTime = System.nanoTime();  
log.debug("{ } 방식을 사용했을 때 걸린 시간은 {}ns", 
formatter.format(endAlternativeMethodTime - 
startAlternativeMethodTime));

Concatenation 연산 대신 Binding Parameter 방식을 사용하였다. Binding Parameter 방식이 가독성도 좋다는 장점이 있다.

Test 3

long startBestMethodTime = System.nanoTime();  
for (int i = 0; i < TEST_CASES; i++) {  
    if (log.isTraceEnabled()) log.trace("i = {}, i + 1 = {}", i, i+1);  
}  
long endBestMethodTime = System.nanoTime();  
log.debug("log level을 미리 확인하는 방식을 사용했을 때 걸린 시간은 {}ns", formatter.format(endBestMethodTime - startBestMethodTime));

아예 로그를 출력하기 전 현재 로그의 레벨을 확인하여 Trace인 경우에만 log.trace가 실행되도록 하였다.

테스트 결과

TEST_CASES가 1억일 때의 결과는 다음과 같다.

image

String Concatenation 방식은 대략 3.53초나 소요된다. 반면 Binding Parameter 방식은 대략 0.48초, log level을 미리 확인하는 방식은 0.07초 밖에 소요되지 않는 유의미한 차이를 보인다.

문제 원인

Test 1

log.trace("i = " + i + ", i + 1 = " + (i+1)); 

log.trace가 수행되기 전에 먼저 String Concatenation 연산이 수행되기 때문이다. Java에서 String을 +로 합하는 과정은 계속 새로운 String을 생성하므로 성능 저하가 발생하는 요소이다. 따라서 TEST_CASES가 1억일 때 String Concatenation 연산이 1억 번 수행되지만 log.trace에서 출력이 되지 않으므로 의미없는 연산으로 인한 성능 저하가 발생한다고 볼 수 있다.

Test 2

log.trace("i = {}, i + 1 = {}", i, i+1); 

Test 1과 Test 2를 비교한 결과를 보면 Binding Parameter 방식인 Test 2가 Test 1에 비해 약 8배가 빠르다고 볼 수 있다. Binding Parameter 방식이 가독성 면에서도 이점이 있으므로 log 메세지 출력에는 Binding Parameter 방식을 사용하는 것이 좋다.

이 때 내가 참고한 첫 번째 Reference에서 Binding Parameter가 3개 이상이면 Object[]를 생성하는 비용이 추가된다고 한다. 따라서 객체를 출력할 때는 toString을 적극 활용하는 것이 좋다고 한다.

하지만 이 방식도 결국 Binding Parameter 연산을 1억 번 수행하게 된다.

// 성능이 조금 떨어지는 방식
log.trace("user - id = {}, username = {}, old = {}", 
user.getId(), user.getUsername(), user.getOld()

//더 좋은 방식
log.trace("user - {}", user);

Test 3

if (log.isTraceEnabled()) log.trace("i = {}, i + 1 = {}", i, i+1);

아예 처음부터 log level을 확인하여 불필요한 연산을 사전에 방지하는 방법이다. Test 3는 Test 1에 비해 약 46배, Test 2에 비해 약 6배가 빠르다고 볼 수 있다. 따라서 더 좋은 성능을 위해서는 처음부터 log level을 확인하는 방식이 유리하다고 볼 수 있다.

문제 해결

log level 확인 + Binding Parameter 방식을 활용하는 것이 가독성 면에서나 성능 면에서 제일 좋다. 이 때 Binding Parameter가 3개 이상이면 성능 저하가 발생함을 감안하고 toString을 적극 활용하자.

결론

아직 내 프로젝트의 수준에서는 워낙 log 출력 수가 작아서 거의 차이를 보이지 않겠지만 규모가 커져 log 출력이 빈번하게 일어날 때는 이 점을 알고 있는 것이 좋을 것 같다. Binding Parameter 방식이 가독성도 좋으므로 Binding Parameter 방식을 적극 활용하는 것이 좋을 것 같다.

Reference

https://dveamer.github.io/backend/HowToUseSlf4j.html https://jsparrow.github.io/rules/avoid-concatenation-in-logging-statements.html#description

MilkTea24 commented 10 months ago

permitAll을 하였음에도 불구하고 Jwt 토큰 검증 예외가 발생하는 이유

Issue

9

문제 상황

SecurityFilterChain 설정에서 authorizeHttpRequests를 사용하여 permitAll을 해줌에도 불구하고 JwtAuthenticationFilter에서 예외가 발생하는 문제였다. 문제가 발생한 EndPoint는 POST /api/users로 회원가입 기능을 위한 EndPoint이므로 JWT 인증이 적용되면 안된다. JwtAuthenticationFilter 내부의 다음과 같이 Jwt 토큰 유효성을 검사하는 코드 부분(parseClaimsJws(jwt))에서 예외가 발생하였다. JwtAuthenticationFIlter.java

@Component  
public class JwtAuthenticationFilter extends OncePerRequestFilter {  
  @Override  
  protected void doFilterInternal(HttpServletRequest request,
   HttpServletResponse response,
   FilterChain filterChain) throws ServletException, IOException {
    ... 
    //JWT에서 정보 얻기  
    Claims claims = Jwts.parserBuilder()  
        .setSigningKey(key) //서명 검증을 위한 SecretKey 입력  
        .build()  
        .parseClaimsJws(jwt) //토큰이 유효한지 검사. 유효하지 않으면 여러 종류 예외 발생
        .getBody();
    ...
    }
}

다음과 같이 /api/users EndPoint를 허용해주었다. SecurityFilterConfig.java

@Configuration  
public class SecurityFilterConfig {
    ...
    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize  
            //허용할 API 목록  
                .requestMatchers("/api/users/login", "/api/tags", "/api/profiles/*").permitAll()  
                .requestMatchers(new RegexRequestMatcher("/api/users", "POST"),  
                    new RegexRequestMatcher("/api/articles", "GET"),  
                    new RegexRequestMatcher("/api/articles/*", "GET"),  
                    new RegexRequestMatcher("/api/articles/*/comments", "GET")).permitAll()  

                    //그 외 API는 모두 인증 필요  
                 .anyRequest().authenticated());
        ...
        return http.build();
    }
}

그럼에도 /api/users에 요청을 보내면 다음과 같은 에러가 발생한다.

java.lang.IllegalArgumentException: JWT String argument cannot be null or empty.

내가 원하는 동작은 permitAll을 해주면 JwtAuthenticationFilter를 거치지 않도록 하는 것이였지만 permitAll을 해도 여전히 에러가 발생했다. 그런데 원인을 알아보던 중 궁금한 점이 생겼다.

jwtAuthenticationFilter는 커스텀 필터인데 permitAll을 했다고 거치지 않도록 할 수 있나? OncePerRequestFilter의 shouldNotFilter와 permitAll은 뭔 차이일까?

이러한 생각을 하고 보니 내가 permitAll에 대해 놓치는 점이 있음을 직감하였다.

문제 원인

결론은 permitAll은 Security Filter Chain 자체를 무시하는 것이 아닌 단지 Security Filter Chain의 Filter는 정상적으로 거치지만 권한 검증 시 모든 요청을 허용하는 것 뿐이다. 그러므로 Security Filter Chain 내부의 JwtAuthenticationFilter는 permitAll을 했음에도 필터가 실행되므로 토큰이 없다는 예외가 발생하는 것이다. permitAll이 Security Filter Chain을 동작하지 않는다고 착각하면 안된다.

아래 Spring Security Reference의 그림만 봐도 이 문제의 모든 원인을 알 수 있다.

image

  1. 먼저 SecurityContextHolder에서 인증(로그인) 정보를 가져온다.
  2. RequestMatcherDelegationAuthorizationManager에서 인증 정보와 EndPoint의 권한 설정을 바탕으로 EndPoint 접근을 허용할지 결정한다.
  3. 접근이 거부되면 AccessDeniedException이 발생하여 이는 ExceptionTranslationFilter에서 처리한다.
  4. 접근이 허용되면 다음 필터에게 넘긴다.

이 그림에서 중요한 점은 모든 Security Filter Chain은 정상적으로 실행되며 특정 EndPoint를 permitAll하면 단지 RequestMatcherDelegationAuthorizationManager가 이 EndPoint의 모든 요청에 대해 AccessDeniedException을 발생시키지 않는 것 뿐이다. 모든 Security Filter Chain이 정상적으로 실행되므로 AuthorizationFilter에서 요청을 통과하는 것과 별개로 JwtAuthentcationFilter가 토큰이 없다는 예외를 뿜는다. 이를 해결하려면 여러 가지 방법이 있다.

해결 방법

나는 1번 방법을 사용하였다. 2번 방법은 Spring Reference에서도 추천하지 않는 방법이다.[링크] 정적 리소스를 가져오더라도 Security Filter Chain을 무시하는 대신 permitAll을 사용하도록 안내하고 있다. Spring Security 6 이전에는 리소스를 불러올 때에도 permitAll을 사용하면 성능 저하가 발생했으나 현재는 permitAll을 사용함으로써 발생하는 성능 저하는 없다고 하니 Spring Security 6 이후 버전을 사용하고 있다면 permitAll을 사용하는 것이 좋다.

1. OncePerRequestFilter의 shouldNotFilter를 활용한다.

JwtAuthenticationFilter는 OncePerRequestFilter를 상속하였으므로 shouldNotFilter를 Override하여 특정 EndPoint에 JwtAuthenticationFilter를 아예 적용하지 않도록 한다.

JwtAuthenticationFIilter.java

import static com.milktea.main.util.security.JwtAuthenticationWhiteList.ALL_METHOD_WHITELIST;  
import static com.milktea.main.util.security.JwtAuthenticationWhiteList.SPECIFIC_METHOD_WHITELIST;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
...
    @Override  
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {  
        String requestPath = request.getServletPath();  

    //모든 Method를 허용하는 API는 antPathMatcher로 검증  
      AntPathMatcher antPathMatcher = new AntPathMatcher();  
        if (Arrays.stream(ALL_METHOD_WHITELIST).anyMatch(pattern -> antPathMatcher.match(pattern, requestPath))) return true;  

    //특정 Method만 허용하는 API는 RegexRequestMatchers로 검증  
      if (Arrays.stream(SPECIFIC_METHOD_WHITELIST).anyMatch(regexRequestMatcher -> regexRequestMatcher.matches(request))) return true;  
        return false;  
    }
}

SecurityFilterConfig.java

import static com.milktea.main.util.security.JwtAuthenticationWhiteList.ALL_METHOD_WHITELIST;  
import static com.milktea.main.util.security.JwtAuthenticationWhiteList.SPECIFIC_METHOD_WHITELIST;

@Configuration  
public class SecurityFilterConfig {
    ...
    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize  
        //허용할 API 목록  
        .requestMatchers(ALL_METHOD_WHITELIST).permitAll()  
        .requestMatchers(SPECIFIC_METHOD_WHITELIST).permitAll()  

        //그 외 API는 모두 인증 필요  
        .anyRequest().authenticated()));
    ...

    return http.build();
    }
}

JwtAuthenticationWhiteList

public class JwtAuthenticationWhiteList {  
    public static final String[] ALL_METHOD_WHITELIST = {  
            "/api/users/login",  
            "api/tags",  
            "api/profiles/*"  
  };  

    public static final RegexRequestMatcher[] SPECIFIC_METHOD_WHITELIST = {  
            new RegexRequestMatcher("/api/users", "POST"),  
            new RegexRequestMatcher("/api/articles", "GET"),  
            new RegexRequestMatcher("/api/articles/*", "GET"),  
            new RegexRequestMatcher("/api/articles/*/comments", "GET")  
    };  
}

Jwt 인증을 적용하지 않을 URL 리스트들을 공통적으로 관리하는 JwtAuthenticationWhitList 클래스를 생성 하였다. 이 클래스를 만들어 실수로 JwtAuthenticationFilter는 통과하였지만 AuthenticationFilter는 통과되지 않는 일을 방지하도록 하였다. 이 클래스에 작성된 URL 리스트를 바탕으로 JwtAuthenticationFilter 적용 여부와 권한 인증 여부를 함께 검사한다.

2. Jwt 인증을 적용하지 않을 URL은 아예 Security Filter Chain을 무시한다.

@Bean  
public WebSecurityCustomizer webSecurityCustomizer() {  
    return (web) -> web.ignoring().requestMatchers(ALL_METHOD_WHITELIST)  
            .requestMatchers(SPECIFIC_METHOD_WHITELIST);  
}

이 방법을 사용하면 지정된 URL에 대해 아예 Securiy Filter Chain이 적용되지 않는다. 이 방법을 사용하면 JwtAuthenticationFilter와 AuthorizationFilter가 모두 적용되지 않으므로 인증과 권한 상관없이 요청을 받을 수 있지만 아래와 같이 친절하게 쓰지마라고 알려준다.

image

제발 permitAll을 써달라고 하니 2번 방법 대신 1번 방법을 사용하자.

결론

역시 아키텍쳐의 완벽한 이해가 선행되어야 한다는 것을 다시 느꼈다. 이번 이슈로 권한과 관련된 내부 동작 과정을 확실하게 이해가 되었다.

Reference

https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html https://velog.io/@choidongkuen/Spring-Security-SecurityConfig-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%9D%98-permitAll-%EC%9D%B4-%EC%A0%81%EC%9A%A9%EB%90%98%EC%A7%80-%EC%95%8A%EC%95%98%EB%8D%98-%EC%9D%B4%EC%9C%A0 https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter/

MilkTea24 commented 9 months ago

LazyInitializationException 해결하기

Issue

9

문제 상황

UserDetails에서 user의 Authorities를 가져오지 못하고 LazyInitializationException이 발생하였다.

에러 코드는 다음과 같다.

org.hibernate.LazyInitializationException: failed to lazily initialize
 a collection of role: com.milktea.main.user.entity.User.authorities:
  could not initialize proxy - no Session

이때까지 지연 로딩을 사용하면서 이런 예외가 발생했던 적이 없었기 때문에 당황했다. 하지만 조금 검색해보니 내가 평소에 무심코 써왔던 Transactional 어노테이션에 해답이 있었다.

문제 원인

결론은 영속성 컨텍스트를 벗어난 객체를 지연 로딩으로 불러오려고 했기 때문이다. 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트를 기본 전략으로 사용하므로 @Transactional 어노테이션이 붙은 메서드 내에서 영속성 컨텍스트가 생성된다. 이후 메서드를 성공적으로 종료하면 수행한 작업들이 커밋되고 영속성 컨텍스트도 함께 적용된다.

이를 내가 작성한 코드에 적용하면 다음과 같다.

  1. BoardUserDetailsService에서 userRepository.findByEmail로 User를 가져온다. 이 때 User와 1:N 매핑이 되어 있는 Authority는 Lazy Loading이 적용되어 있으므로 proxy로 가져와 나중에 조회되면 언제든지 실제 데이터를 가지고 올 준비를 한다.

  2. 하지만 Authority를 조회하지 않고 BoardUserDetails 객체를 생성해버렸다. 메서드가 종료되었으므로 User를 관리하는 영속성 컨텍스트도 함께 종료되어 User는 더 이상 영속성 컨텍스트의 관리를 받지 않는다.

  3. BoardUserDetails에서 뒤늦게 Authority를 조회하여 LazyInitializationException이 발생한다. 영속성 컨텍스트의 관리를 받지 않는 User의 Authority proxy를 조회하므로 실제 데이터를 가져오지 못하고 LazyInitializationException이 발생하게 된다.

이를 해결하기 위해서는 영속성 컨텍스트가 끝나기 전에 Authority의 실제 데이터를 가져와야 한다.

해결 방법

1. Authority의 fetch type을 Eager로 변경한다.

User를 조회하면 이후 Authority도 함께 실제 데이터로 조회되도록 Eager로 변경한다. 이 방식을 사용하면 Authority는 User를 조회하면 항상 실제 데이터를 가져오기 때문에 LazyInitializationException을 원천 차단할 수 있다.

다만 이 fetch type을 Eager로 변경하면 Authority가 필요없을 때에도 항상 가져오기 때문에 성능 저하를 감수해야 한다. 아직 다른 API를 개발하지 않았지만 처음 로그인을 할 때 DB에서 Authority 확인을 하고 이후 JWT에 담겨진 Authority를 사용하기 때문에 Authority가 필요한 경우는 로그인 외 거의 없을 것이라 예상된다. 이 경우 User 100명을 불러올 때 사용하지도 않을 Authority를 불러오기 위해 100번의 쿼리가 추가로 발생할 것이다. 따라서 Lazy Loading을 사용하되 다른 방법을 고안해야 한다.

2. fetch join(Entity Graph)를 사용한다.

N + 1 문제를 회피하면서 연관된 데이터를 함께 불러오는 방법은 fetch join이 있다. User와 연관된 Authority까지 하나의 쿼리로 가져올 수 있다. @EntityGraph는 Spring Data JPA에서 fetch join을 적용해주는 어노테이션으로 @EntityGraph를 사용하여 해결하였다.

fetch join을 사용하면 User와 Authority를 하나의 쿼리만 사용하여 모두 가져올 수 있다. Authority가 필요 없을 때는 fetch join을 적용한 메서드 대신 일반 메서드로 조회를 하여 proxy로 가져오게 하면 된다.

현재 issue에서는 하나의 데이터만 가져오므로 상관없지만 findAll로 조건을 만족하는 모든 엔티티들을 fetch join으로 조회할 때는 중복되는 데이터를 고려해야 한다. 예시로 한 User가 두 개의 Authority를 가지고 있다고 다음과 같이 가정한다. image

fetch join을 할 때 실행되는 쿼리를 보면 다음과 같다.

image

이를 실제로 데이터베이스에서 직접 조회하면 다음과 같은 결과를 얻는다.

image

JPA는 이 중복되는 User를 제거하지 않고 그대로 가져온다. 따라서 findAll로 가져왔다면 List 내부에 username이 Jacob인 User Entity가 두 개 담기는 것이다. 이 두 개의 엔티티는 동일한 객체(동일한 주소)이다. 이러한 중복되는 데이터는 fetch join을 사용하여 여러 데이터를 불러올 때 필연적으로 발생하므로 distinct 키워드를 사용해야 한다. (추가! : EntityGraph 어노테이션은 이제 자동적으로 중복을 제거해준다고 한다.)

결론

이 때까지 이런 예외가 발생하지 않았던 이유는 Service 단에서 모든 데이터를 조회해 DTO로 전송하였기 때문이다. Service Layer를 분리하여 이 Layer 내에서 모든 데이터를 조회해서 DTO로 담아 Controller로 보냈던 방법은 이러한 문제를 근본적으로 회피하기에 아주 좋은 전략인 것이다. 그래서 UserDetailsService에서 이 이슈를 처음 마주쳤지 않았나 생각이 든다. 이런 이슈를 볼 때마다 스프링이 객체 지향 설계를 얼마나 잘 되어있는지 새삼 느끼게 된다.

Reference

https://ssdragon.tistory.com/116 https://junhyunny.github.io/javascript/auto-distinct-when-using-entity-graph/ https://vladmihalcea.com/the-best-way-to-handle-the-lazyinitializationexception/

MilkTea24 commented 9 months ago

HttpServletRequestWrapper 적용하기

Issue

9

문제 상황

로그인할 때 비밀번호 일치 여부를 파악하고 jwt 토큰을 반환하는 핵심 로직은 InitialAuthenticationFilter에서 이루어진다. 사실 jwt 토큰을 반환하므로 컨트롤러는 사용할 필요가 없지만 사전 정의된 API에서 로그인 완료 시에도 user 데이터를 요구하는 바람에.. Controller에서 데이터를 받아 유저를 반환하는 로직을 짜야한다.

이 때 InitialAuthenticationFilter에서 HttpServletRequest의 getInputStream으로 값을 한 번 얻어내면 Controller에서 다시 값을 얻어낼 수 없다는 문제가 발생한다.

문제 원인

앞서 언급한대로 Controller에서 값을 받기 전 Filter에서 request의 body를 한 번 가져왔다면 Controller에서 값을 가져올 수 없다. 그러므로 HttpServletRequestWrapper를 정의하여 Controller에 HttpServletRequestWrapper를 전달해야 한다.

해결 방법

HttpServletRequestWrapper에 Json 문자열을 저장하는 필드를 하나 만든다. 처음 생성 시에 HttpServletRequest에서 InputStream을 읽어 Wrapper에 저장한다. 이후 HttpServletRequest 대신 HttpServletRequestWrapper를 전달하여 다른 필터나 컨트롤러가 InputStream을 호출할 때마다 Wrapper에 캐싱되어 있는 Json 문자열을 반환해주면 되는 원리다. 이 방식을 사용하면 HttpServletRequest의 InputStream은 한 번만 호출되면서 여러 번 Request Body를 가져올 수 있다.

1. Custom HttpServletRequestWrapper 정의하기

public class LoginHttpServletRequestWrapper extends HttpServletRequestWrapper {  
    private final String body;  

    public LoginHttpServletRequestWrapper(HttpServletRequest request) throws IOException {  
        super(request);  

        body = copyRequestBodyToString(request);  
    }  

    @Override  
  public ServletInputStream getInputStream() throws IOException {  
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());  
        ServletInputStream servletInputStream = new ServletInputStream() {  
            @Override  
  public boolean isFinished() {  
                return byteArrayInputStream.available() == 0;  
            }  

            @Override  
  public boolean isReady() {  
                return true;  
            }  

            @Override  
  public void setReadListener(ReadListener listener) {  
                throw new UnsupportedOperationException();  
            }  

            public int read() throws IOException {  
                return byteArrayInputStream.read();  
            }  
        };  

        return servletInputStream;  
    }  

    private String copyRequestBodyToString(HttpServletRequest request) throws IOException {  
        try {  
            ServletInputStream servletInputStream = request.getInputStream();  
            return StreamUtils.copyToString(servletInputStream, StandardCharsets.UTF_8);  
        } catch (IOException e) {  
            log.error("request의 body를 읽어들이던 중 문제가 발생하였습니다.");  
            if (log.isDebugEnabled()) log.debug("위치 - {},\n 에러 stack\n - {}",
             this.getClass().getName(), ExceptionUtils.getStackTrace(e));  
            throw e;  
        }  
    }  
}

2. InitialAuthenticationFilter에서 HttpServletRequestWrapper 사용하기

public class InitialAuthenticationFilter extends OncePerRequestFilter {  
    ...

    @Override  
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
      FilterChain filterChain) throws ServletException, IOException {  
            LoginHttpServletRequestWrapper loginRequestWrapper = 
                new LoginHttpServletRequestWrapper(request);  
            String body = "";  
            try {  
                StringBuilder stringBuilder = getRequestToStringBuilder(loginRequestWrapper, 
                    response, filterChain);  
                body = stringBuilder.toString();  
            } catch (IOException e) {  
                ...
            }  

            UserLoginRequest loginRequest = new Gson().fromJson(body, UserLoginRequest.class);  
            String email = loginRequest.userLoginDTO().email();  
            String password = loginRequest.userLoginDTO().password();  

            //얻은 이메일과 비밀번호로 인증 로직 진행

            filterChain.doFilter(loginRequestWrapper, response);  
    }

private StringBuilder getRequestToStringBuilder(LoginHttpServletRequestWrapper 
    loginRequestWrapper, HttpServletResponse response, FilterChain filterChain)
     throws IOException {  
    StringBuilder stringBuilder = new StringBuilder();  
    try (InputStreamReader inputStreamReader = 
        new InputStreamReader(loginRequestWrapper.getInputStream());  
         BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {  
        String line = "";  
        while (!Objects.isNull(line = bufferedReader.readLine())) {  
            stringBuilder.append(line);  
        }  
    } catch (IOException e) {  
        ...
    }  

    return stringBuilder;  
}
  1. LoginHttpServletRequestWrapper를 생성한다.
  2. getRequestToStringBuilder로 Wrapper에 저장된 body를 String Builder로 변환한다. LoginHttpServletRequestWrapper에서 가져온 InputStream을 BufferedReader를 통해 StringBuilder에 저장한다.
  3. 가져온 Json String을 GSON 라이브러리를 이용하여 객체(Request DTO)로 변환한다.
  4. 인증 로직을 진행한다.
  5. 다음 필터나 컨트롤러가 Request Body를 또 가져올 수 있도록 다음 필터에 loginRequestWrapper를 전달한다.

결론

HttpServletRequestWrapper 클래스는 HttpServletRequest의 Decorator 패턴이라고 할 수 있다. 내가 구현한 Wrapper는 기존 HttpServletRequest에서 Request Body를 캐싱할 수 있는 기능을 추가한 데코레이터 패턴인 것이다. 따라서 HttpServletRequest에서 추가적인 로깅, 검증 등의 기능 추가가 필요하면 이제 HttpServletReuqestWrapper 클래스를 활용하도록 하자. 이 HttpServletRequestWrapper를 사용하면 클라이언트에서 어떻게 요청을 전송했는지 로그로 남길 수 있게 만들 수 있을 것 같다.

Reference

https://howtodoinjava.com/java/servlets/httpservletrequestwrapper-example-read-request-body/ https://www.baeldung.com/java-servlet-request-set-parameter https://modimodi.tistory.com/74 https://docs.oracle.com/javaee/7/tutorial/servlets013.htm

MilkTea24 commented 9 months ago

static 메서드 Mocking은 안티패턴일까?

Issue

9

문제 상황

Jwt 토큰을 생성할 때 토큰의 만료 시간을 토큰 생성 시간부터 30분 후로 설정하였다. 이 때 30분 지나면 진짜로 만료가 되는지 여부를 어떻게 테스트해야 할까? 이 문제와 별개로 Instant.now()를 이용해서 만료 시각을 설정하는데 Instant.now(), LocalDateTime.now()와 같은 코드는 테스트할 때마다 다른 값을 얻기 때문에 테스트가 항상 성공한다고 보장할 수 없는 문제도 있어 이를 해결해야 했다.

//발급
Instant expiredInstant = Instant.now().plusSeconds(1800L);  
Claims claims = Jwts.claims()  
        .setExpiration(Date.from(expiredInstant));
...

//검증
claims = Jwts.parserBuilder()  
        .setSigningKey(key) //서명 검증을 위한 SecretKey 입력  
        .build()  
        .parseClaimsJws(parsingJwt) //토큰이 유효한지 검사. 유효하지 않으면 여러 종류 예외 발생  
        .getBody();
//ExpiredJwtException을 발생시키고 싶은데 30분을 기다릴 수는 없으므로 
//그리고 테스트시 발급 시간이 계속 달라지는 문제

문제 원인

Instant.now()가 코드로 사용하기 그리 좋은 코드는 아니라는 것을 공부하면서 알게 되었다. Instant.now()는 동일한 입력에 다른 출력을 반환하므로 static 메서드로 사용하기 적합한 순수 함수가 아니다. 따라서 이를 해결하기 위해 자연히 Instant.now()를 mocking하는 방법을 찾게 되고 이 과정에서 테스트하기 어려워 진다는 것이다.

왜 static 메서드의 Mocking을 권장하지 않는가?

static 메서드 자체가 적절하게 사용하지 않으면 안티 패턴이 될 수 있다고 한다. java.lang.Math와 같은 순수 함수적인 특성("purely functional")을 가진 static 메서드는 적절히 사용한 예시이다. 반면 static 메서드가 의존성을 가지고 다른 객체와 상호작용하는 시점부터 안티 패턴이 된다고 한다. 다른 객체로부터 영향을 받기 때문에 동일한 입력이 다른 결과를 낳을 수 있는 것이다. 애초에 동일한 입력에 동일한 출력을 반환하는 순수 함수였다면 내부 동작을 mocking으로 강제할 이유가 없다. 그러니까 static 메서드를 mocking해야 할 일이 있다면 이 static 메서드가 다른 객체에 영향을 받고 있는 것은 아닐까?라고 먼저 생각해보아야 한다. 이와 관련해서 구글링하던 중 이와 관련된 아주 심플한 이 있어 소개한다.

If it’s a pure function, why are you mocking the type? Do you also mock arrays or strings when your functions depend on their methods, or do you just pass a real array or a real string? If it’s a pure function, you should learn everything you need from making assertions on the return value.

순수 함수로 접근하는 Instant.now()와 Instant.now(Clock)

이러한 관점에서 Instance.now()는 순수 함수가 아니므로 Instance.now()를 Mocking할 수 밖에 없는 것이다. Instant.now()는 동일한 입력(null 입력)에 항상 다른 결과가 나온다. 함수 내부에서 시스템의 Clock에 영향을 받는 것이다. 이렇게 순수함수가 아니고 다른 객체에 계속 영향을 받기 때문에 우리는 이를 테스트하기 위해 mocking을 해야 하는지 고민하는 것이다.

Instant.now(Clock)은 동일한 Clock 입력이 들어오면 항상 동일한 Instant를 반환한다. 입력이 동일하면 출력도 동일한 순수 함수인 것이다. 따라서 static 메서드를 사용한 적절한 예시이다. 이 경우 테스트도 간편하여 Instant.now(Clock) static 함수를 mocking할 필요 없이 테스트할 Clock 객체를 생성하여 이 static 함수에 전달하면 된다. Production 단계에서는 Instant.now에 현재 시간 정보를 가지고 있는 Clock 객체를 전달하면 되는 것이다.

해결 방법

Clock 객체를 빈으로 등록하여 테스트 시 이 빈의 Mock을 정의하고 Instance.now(Clock)을 호출하는 방법으로 해결할 수 있었다. Clock 객체의 instant 메서드는 static 메서드가 아니므로 Mockito를 사용하여 편리하게 원하는 시간을 Mocking할 수 있다.

production 코드 변경하기

TimeConfig.java

//테스트 시 Clock 빈을 Mock으로 변경
@Configuration  
public class TimeConfig {  
    @Bean  
  public Clock clock() {  
        return Clock.systemDefaultZone();  
    }  
}

JwtTokenAdministrator.java

Instant expiredInstant = Instant.now(clock).plusSeconds(1800L);  
Claims claims = Jwts.claims()  
        .setExpiration(Date.from(expiredInstant));
...

//검증
claims = Jwts.parserBuilder()  
        .setSigningKey(key) //서명 검증을 위한 SecretKey 입력  
        .setClock(() -> Date.from(clock.instant()))  
        .build()  
        .parseClaimsJws(parsingJwt) //토큰이 유효한지 검사. 유효하지 않으면 여러 종류 예외 발생  
  .getBody();

이때 Jwts의 parserBuilder에서 주의해야 할 점이 있다. setClock에서 Clock을 매개 변수로 받는데 java.time.Clock클래스가 아닌 io.jsonwebtoken.Clock 인터페이스이다. 이 인터페이스는 Date를 반환하는 now() 메서드를 가진 함수형 인터페이스이다. 따라서 람다 함수로 검증 당시 시각의 정보를 Date 클래스로 반환하면 된다. 이름이 동일하므로 java.time.Clock을 바로 넣는 실수를 할 수 있으니 주의하자.

테스트 코드 변경하기

@BeforeEach  
void setup() {  
    Mockito.mock(Clock.class);  
    ...
}

@Test  
@DisplayName("토큰 만료 실패 테스트")  
void verify_token_expired_fail_test() throws ServletException {  
 //given  
 //현재 시간 변경(생성 시간은 2024-01-01T10:00:00Z 현재 시간은 이로부터 한시간 뒤)  Mockito.when(clock.instant()).thenReturn(Instant.parse("2024-01-01T11:00:00Z"));  

 //when  
 //then  Assertions.assertThrows(ServletException.class, () -> jwtTokenAdministrator.verifyToken(token));  
}

clock.instant()는 static 메서드가 아니므로 mock을 편리하게 적용할 수 있다.

결론

static 메서드를 mocking 해야 한다면 애초에 static 메서드에 어떠한 문제가 있다는 것을 명심해야겠다. 순수 함수가 아닐 때는 static 메서드로 구현하지 않는 것이 좋다. 실제로 Instance.now()를 mocking하는 일보다 코드를 Instance.now(Clock)으로 바꾸는 것이 테스트하기에도 훨씬 좋았다. LocalDate, LocalDateTime의 경우도 마찬가지이므로 평소에 LocalDateTime.now(Clock)을 이용하자.

Reference

https://yeonyeon.tistory.com/258 https://velog.io/@betterfuture4/Static-%EB%A9%94%EC%86%8C%EB%93%9C%EB%A5%BC-mocking%ED%95%98%EC%A7%80-%EB%A7%90%EC%9E%90-feat.-LocalDate.nowclock https://stackoverflow.com/questions/9367610/are-static-methods-a-di-anti-pattern https://medium.com/@_ericelliott/if-its-a-pure-function-why-are-you-mocking-the-type-82432260d3b9