오류 해결 - SpringSecurity 순환 참조

MeteorLee·2023년 4월 9일
0

오류 상황

스프링 시큐리티 사용 중 발생하는 순환 참조 오류

발생 과정

스프링 시큐리티를 사용하는 상황에서 이런 저런 설정을 만지고 프로젝트를 실행하니 갑자기 에러가 발생했다.

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2023-04-09 00:23:20.816 ERROR 16504 --- [  restartedMain] o.s.b.d.LoggingFailureAnalysisReporter   : 

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  customSecurityConfig defined in file [C:\Book\javaWeb\b01\build\classes\java\main\org\zerock\b01\config\CustomSecurityConfig.class]
↑     ↓
|  customUserDetailsService defined in file [C:\Book\javaWeb\b01\build\classes\java\main\org\zerock\b01\security\CustomUserDetailsService.class]
└─────┘


Action:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.


Process finished with exit code 0

사실 처음 만났던 에러는 아니었지만 이전의 해결 과정이 문제가 있었기에 이번에는 조금 더 공부를 하여 에러를 해결했다. (사실 공부라기 보단 그냥 내가 부족했던 것이다.)

직접적인 오류 발생 과정

스프링 시큐리티를 설정하였기에 자연스럽게 PasswordEncoder를 스프링 시큐리티 설정을 담당하는 클래스 CustomSecurityConfig에 입력했다. 그 후 로그인 유지를 위한 remember-me를 이용하기 위해서 CustomUserDetailsService를 userDetailsService로 주입 받게 되었다.

  
@Log4j2  
@Configuration  
@RequiredArgsConstructor  
// 어노테이션으로 권한 설정 @PreAuthorize, @PostAuthorize 이용해서 사후, 사전 권한 설정  
@EnableGlobalMethodSecurity(prePostEnabled = true)  
public class CustomSecurityConfig {  
  
  
  // 자동 로그인 remember-me 설정 주입 필요  
  private final DataSource dataSource;  
 private final CustomUserDetailsService userDetailsService;  
  
  // 로그인 없이 일단 사용할 수 있도록 처리  
  @Bean  
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
  
  log.info("========================config====================================");  
  
  // login 설정  
  http.formLogin().loginPage("/member/login");  
  
  // csrf 비활성화  
  http.csrf().disable();  
  
  // remember-me 설정  
  http.rememberMe()  
  .key("12345678")  
  .tokenRepository(persistentTokenRepository())  
  .userDetailsService(userDetailsService)  
  .tokenValiditySeconds(60 * 60 * 24 * 30);  
  
  
  
 return http.build();  
  
  }  
  
  // 정적 파일 시큐리티 적용 제외  
  @Bean  
  public WebSecurityCustomizer webSecurityCustomizer() {  
  
  log.info("---------------------------web configure------------------------------");  
  
  
 return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());  
  }  
  
  
  // PasswordEncoder 설정  
  @Bean  
  public PasswordEncoder passwordEncoder() {  
  return new BCryptPasswordEncoder();  
  }  
  
  // remember-me를 위한 토큰 레포지토리 빈  
  @Bean  
  public PersistentTokenRepository persistentTokenRepository() {  
  JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();  
  repo.setDataSource(dataSource);  
 return repo;  
  }  
  
}

그리고 개발 과정인 만큼 간결한 확인을 위해서 UserDetailsService를 구현한 CustomUserDetailsService에서 직접적으로 User클래스를 이용해서 UserDetails를 설정하였다.

@Log4j2
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final PasswordEncoder passwordEncoder;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        log.info("loadUserByUsername = " + username);

        UserDetails userDetails =User.builder()
                .username("user1")
                .password(passwordEncoder.encode("1111"))
                .authorities("ROLE_USER")
                .build();


        return userDetails;
    }
}

이러한 상황에서 위의 에러가 발생하였다.

오류 해결 과정

오류 파악

일단 콘솔에 나온 에러를 해석해보면

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.

대략 순환 참조를 권장하지 않고 금지하고 있으니 빈의 종속성의 주기(cycle)를 제거해달라는 것이다. 사실 이전에 봤을 때는 이게 무슨 뜻인지 정확하게 몰랐는데 빈을 주입하는 개념을 조금 더 잘 알게된 지금은 이제 무슨 의미인지 알게 되었다.

대략적으로 빈 사이의 주입 과정에서 서로가 서로를 참조하는 꼬리무는 과정이 있기 때문에 발생하는 오류라는 것이다.

해결 과정

이전에도 만난적 있던 문제였기에 조금은 더 알아보고 고치려고 했기에 검색을 통해 baeldung 사이트를 참조 하게 되었다.

https://www.baeldung.com/circular-dependencies-in-spring

문제 인식

일단 정확하게 순환 참조가 무엇인지 알아보면

  1. What Is a Circular Dependency?
    A circular dependency occurs when a bean A depends on another bean B, and the bean B depends on bean A as well:
    Bean A → Bean B → Bean A
    Of course, we could have more beans implied:
    Bean A → Bean B → Bean C → Bean D → Bean E → Bean A

서로가 서로를 참조하여서 발생하는 문제로 종속성이 서로가 의존할 때 발생하는 문제이다. 그리고 마치 순환하는 구조처럼 보이기에 circular라는 말이 붙은 것이었다.

스프링에서의 순환 참조

When the Spring context loads all the beans, it tries to create beans in the order needed for them to work completely.
Let's say we don't have a circular dependency. We instead have something like this:
Bean A → Bean B → Bean C
Spring will create bean C, then create bean B (and inject bean C into it), then create bean A (and inject bean B into it).
But with a circular dependency, Spring cannot decide which of the beans should be created first since they depend on one another. In these cases, Spring will raise a BeanCurrentlyInCreationException while loading context.
It can happen in Spring when using constructor injection. If we use other types of injections, we shouldn't have this problem since the dependencies will be injected when they are needed and not on the context loading.

스프링의 순환 참조에 대해서 알아보던 중에 가장 크게 놀랐던 부분이 바로 빈의 생성 과정에서 발생한다는 것이다. 그러니까 단순하게 참조가 순환의 고리 형태라서 발생하는 것이 아닌 빈을 생성하는 과정에서 빈의 생성 순서를 스프링에서 모르는 것이 이런 에러를 발생시키는 것이었다. 만약 빈의 A -> B -> C -> A 의 순환 형태의 구조를 가지고 있더라도 만약 빈의 생성 순서를 스프링에서 알게 만들 수 있다면 이런 순환 참조 에러가 발생하지 않는다는 것이다.

해결 방법

@Lazy

이런 순환 참조를 끊는 간단한 방법으로는 @Lazy 어노테이션을 사용하는 것이다.

A simple way to break the cycle is by telling Spring to initialize one of the beans lazily. So, instead of fully initializing the bean, it will create a proxy to inject it into the other bean. The injected bean will only be fully created when it’s first needed.

프록시 패턴을 이용해서 빈을 생성할 때 프록시 빈을 만든 후에 실제 빈을 호출할 때 빈을 만들어서 주입하는 방식을 사용하는 것이다. 이렇게 되면 순환하는 구조를 가지더라도 실제 빈이 아닌 프록시 빈이 생성되는 것이므로 에러가 발생하지 않는 것이다.

조금 더 궁금하므로 @Lazy에 대한 공식 문서를 살펴보자

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/Lazy.html

In addition to its role for component initialization, this annotation may also be placed on injection points marked with Autowired or Inject: In that context, it leads to the creation of a lazy-resolution proxy for all affected dependencies, as an alternative to using ObjectFactory or Provider. Please note that such a lazy-resolution proxy will always be injected; if the target dependency does not exist, you will only be able to find out through an exception on invocation. As a consequence, such an injection point results in unintuitive behavior for optional dependencies. For a programmatic equivalent, allowing for lazy references with more sophistication, consider ObjectProvider.

프록시, 빈을 사용할 때 주입 등 같은 내용이 대부분인데 다른 것은 사용하지 않는 것을 추천한다는 내용이다.

스프링은 빈을 스프링 컨텍스트에서 관리해주므로 우리는 빈을 신경쓰지 않고 서비스 로직에 집중하는 방식을 사용하는 것이다. 하지만 그래도 우리는 빈이 언제 생성되고 언제 소멸하는 등 이런 빈에대한 정보를 잘 알고 사용해야 한다. 어쩌면 이런 순환 참조도 빈의 생성 과정에대한 깊은 이해가 없기 때문에 발생한 일인데 이런 직관적인 빈의 생성 과정에 대한 이해를 하지 않고 문제를 해결하기 위해 @Lazy를 사용하는 것 같아서 좋지 않다는 내용이다.

물론 다른 관점의 내용도 있다. 굉장히 레거시한 코드의 구조 내부에서는 빈의 생성 구조들이 이미 엉킬대로 엉켰기 때문에 @Lazy를 사용하여 동작이 가능하게 하는 것이 더 중요하다는 것이다. 레거시한 코드를 모두 고칠 생각이 아니라면 동작이 가능하게 하는 방법이 더 좋다는 좀 해결 방법?이라기 보다는 임시 방편에 가까운 방법이지만 이런 상황에 놓인다면 선택권이 없다는 것이다.

Setter 주입

One of the most popular workarounds, and also what the Spring documentation suggests, is using setter injection.
Simply put, we can address the problem by changing the ways our beans are wired — to use setter injection (or field injection) instead of constructor injection. This way, Spring creates the beans, but the dependencies are not injected until they are needed.

자바의 공식 문서에서 제안한 방법으로 Setter로 주입하는 방식이다. 빈이 생성은 되지만 사용되기 전에는 종속성을 주입하지 않는 것으로 이렇게 되면 빈이 사용되기 전까지는 참조가 없기 때문에 순환 참조가 발생하지 않는 것이다. 하지만 Setter방식의 주입 방식은 그다지 추천되지 않는 방식이다.

@PostConstruct

Another way to break the cycle is by injecting a dependency using @Autowired on one of the beans and then using a method annotated with @PostConstruct to set the other dependency.

생성자 주입이 다 이루어진 후에 메서드를 실행하는 것을 명시하는 어노테이션으로 @PostConstruct의 어노테이션을 붙인 메서드를 통해서 의존성을 주입하는 방식으로 해결하는 것이다.

하지만 @PostConstruct는 빈의 생성 주기에서 발생하는 것이지만 의존성 주입의 순서가 명시되는 점에서 오류가 발생할 가능성이 높아진다. 스프링의 가장 큰 장점은 빈의 생성 주기나 이런 의존성 주입을 스프링이 관리해주는 것인데 이런 장점을 깍아먹는 일이다. 스프링에서 빈을 관리할 때는 올바른 순서로 주입되던 의존성이 @PostConstruct를 통해서 순서를 정해주었기 때문에 발생하는 오류가 생길 가능성이 있다는 것이다.

ApplicationContextAware 와 InitializingBean를 구현

If one of the beans implements ApplicationContextAware, the bean has access to Spring context and can extract the other bean from there.
By implementing InitializingBean, we indicate that this bean has to do some actions after all its properties have been set. In this case, we want to manually set our dependency.

ApplicationContextAware는 빈으로 사용될 객체의 스프링 컨테이너에 직접 접근해서 설정을 하는 방법이다.

InitializingBean는 빈으로 등록된 이후에 수행할 작업을 명시하는 것인데 이 이 수행할 작업에서 빈을 주입하는 것이다.

위의 작업들과 마찬가지로 스프링 고유의 장점을 저버리는 방식이고 또 다른 오류를 발생시킬 가능성이 있는 방식들이다.

✅ 재설계

When we have a circular dependency, it’s likely we have a design problem and that the responsibilities are not well separated. We should try to redesign the components properly so that their hierarchy is well designed and there is no need for circular dependencies.
However, there are many possible reasons we may not be able to do a redesign, such as legacy code, code that has already been tested and cannot be modified, not enough time or resources for a complete redesign, etc. If we can't redesign the components, we can try some workarounds.

순환 참조라는 것은 결국 서로가 서로를 참조하는 닭과 계란의 관계와 같은 상황을 가지는 것이다. 이런 방법을 해결하는 가장 좋은 방식은 이런 빈의 참조 관계를 재설계하는 것이다.

내가 오류를 해결한 방식이고 나는 PasswordEncoder를 다른 @Configuration 클래스에서 빈으로 등록하는 방식을 사용했다.

  
@Log4j2  
@Configuration  
public class PasswordEncoderConfiguration {  
  
  
  // PasswordEncoder 설정  
  @Bean  
  public PasswordEncoder passwordEncoder() {  
  return new BCryptPasswordEncoder();  
  }  

  
}

결국 A -> B -> C -> 의 구조에서 A -> B , B -> C의 구조로 빈의 참조 구조를 변경함으로써 순환 구조를 끊어내어 오류를 해결하였다.

생각

공식 문서

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans

공식 문서의 내용을 다시 보면서 느낀 점은 처음 설계가 중요하다는 것이다. 스프링의 시작 시점에서 빈을 등록하는 과정과 스프링의 개념을 다시 생각해보면 내가 만난 순환 참조가 어쩌면 스프링의 가장 큰 장점 중에 하나였던 것이다. 스프링이 빈을 관리해주기 때문에 개발자가 서비스 로직에만 집중할 수 있다는 장점을 이번 에러를 통해서 더욱 알게 되었다.

You can generally trust Spring to do the right thing. It detects configuration problems, such as references to non-existent beans and circular dependencies, at container load-time. Spring sets properties and resolves dependencies as late as possible, when the bean is actually created. This means that a Spring container that has loaded correctly can later generate an exception when you request an object if there is a problem creating that object or one of its dependencies — for example, the bean throws an exception as a result of a missing or invalid property. This potentially delayed visibility of some configuration issues is why ApplicationContext implementations by default pre-instantiate singleton beans. At the cost of some upfront time and memory to create these beans before they are actually needed, you discover configuration issues when the ApplicationContext is created, not later. You can still override this default behavior so that singleton beans initialize lazily, rather than being eagerly pre-instantiated.

공식 문서에서 스프링이 컨테이너에 올라갈 때 이런 에러를 잡아주기 때문에 실제 애플리케이션이 작동 중에 발생하는 종속성 오류와 구성 오류 등을 예방할 수 있다는 것이다. 사실 여러 해결 방식 중에 @Setter나 @PostCunstruct나 결국 다른 오류를 발생시킬 가능성이 있는 완벽한 해결방식이 아닌 것처럼 시작 하는 상황에서 오류를 발생시켜서 개발자가 불완전한 구조를 계속해서 사용하는 것을 예방해 주는 것이라고 생각한다.

😅생각

@RequiredArgsConstructor를 이용한 생성자 주입을 즐겨 사용하였고 가장 좋은 방식으로 알고 있었기에 이런 오류를 발생시킬 수 있다는 생각을 하지 못했었다. 처음에는 이런 생성자 주입 방식에서 발생한 문제기에 이런 방식이 항상 좋은 것은 아니다. 라는 생각을 가지게 되었다. 하지만 어쩌면 이번 오류는 내가 문제가 있는 순환 참조 구조를 애플리케이션에 설계하지 못하게 막아주는 것이었다.

이번 오류는 단순하게 애플리케이션의 어떤 동작에 발생하는 오류가 아닌 구조적인 측면에서 발생한 오류인 만큼 조금 스프링의 기본적인 동작 구조에 대한 공부를 많이 하게 되었다.

또한 설계의 중요성을 다시 생각하게 되었다. 사실 작은 프로젝트이고 개발을 하는 초반 상황에서 이런 설계적인 오류가 발생한다는 것에 놀랐다. 그래서 정말 정말 설계가 중요하다고 다시 한번 더 생각하게 되었다.

도움 받은 곳
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans
https://www.baeldung.com/circular-dependencies-in-spring
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/Lazy.html
https://blog.naver.com/simpolor/221919272642
https://velog.io/@limsubin/Spring-Boot%EC%97%90%EC%84%9C-PostConstruct-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://antop.tistory.com/entry/Spring-Lazy

profile
코딩 시작

0개의 댓글