글 작성자: beaniejoy

Spring Security만을 사용해서 개인 프로젝트에 간단한 회원가입과 인증 프로세스를 개발하면서 부딪혔던 내용 중 하나를 정리하고자 합니다. 

Spring Security 설정파일 작성 후 애플리케이션 실행 시 발생했던 순환참조(circular references, dependency cycle)에 대해 기록한 내용입니다.

 

📌 1. 개발했던 내용

오직 Spring Security 내용을 가지고 인증 프로세스를 구현했던 내용을 정말 간단하게 요약하고 문제상황을 보여드리는게 좋을 것 같습니다.

/**
 * 실제 인증 절차 수행
 * @property userDetailsService email로 계정 찾기
 */
@Component
class ApiAuthenticationProvider(
    private val userDetailsService: UserDetailsService,
    private val passwordEncoder: PasswordEncoder
) : AuthenticationProvider {
    companion object: KLogging()

    override fun authenticate(authentication: Authentication): Authentication {
        logger.info { "start authentication" }

        val email = authentication.name
        val password = authentication.credentials as String?

        val user = userDetailsService.loadUserByUsername(email)
        if (!passwordEncoder.matches(password, user.password)) {
            throw BadCredentialsException("Input password does not match stored password")
        }

        // password null로 반환
        return UsernamePasswordAuthenticationToken(email, null, user.authorities)
    }

    override fun supports(authentication: Class<*>): Boolean {
        return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
    }
}

Api 방식의 AuthenticationFilter를 적용하면서 authenticate를 실제 수행하는 Provider를 custom하기 위한 구현체를 적용했습니다. 여기서 @Component로 bean 객체 등록을 했었습니다. (이것이 순환참조 발생의 발단이 됩니다.)

해당 Provider는 UserDetailsService 구현체에서 실제 인증 대상 사용자 객체를 받아 password 비교를 통한 인증 과정을 수행하고 있습니다.
password 비교는 주입 받은 PasswordEncoder 객체가 수행합니다.

@Configuration
@EnableWebSecurity
class SecurityConfig {
	//...
    
    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
}

PasswordEncoder bean은 따로 Security Config 파일에서 등록해줬습니다.

Spring Security를 활용한 회원가입, 로그인(인증) 프로세스에 대해서는 따로 정리하고자 합니다.

 

📌 2. 초기 설정 내용

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Autowired
    lateinit var apiAuthenticationProvider: apiAuthenticationProvider
    
    //...
    
    @Bean
    fun authenticationManager(): AuthenticationManager {
        val authenticationManager = authenticationConfiguration.authenticationManager as ProviderManager
        authenticationManager.providers.add(apiAuthenticationProvider)
        return authenticationManager
    }
    
    //...
}

api 전용 custom filter를 설정하기 위해서 AuthenticationManager를 따로 custom filter에 등록을 해야하는 과정이 필요해서 AuthenticationManager를 따로 bean 등록했습니다. 여기에는 따로 개발해두었던 AuthenticationProvider 구현체를 직접 필드 주입을 통해 주입받아서 AuthenticationManager에 추가했습니다.

 

📌 3. 문제 발생

위 설정을 가지고 애플리케이션 실행하면 다음과 같은 에러가 발생하면서 실행이 중지됩니다.

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

┌─────┐
|  securityConfig (field public io.beaniejoy.dongnecafe.common.security.ApiAuthenticationProvider io.beaniejoy.dongnecafe.common.config.SecurityConfig.apiAuthenticationProvider)
↑     ↓
|  apiAuthenticationProvider defined in file [/Users/beanie.joy/Dev/project/dongne-cafe-api/dongne-account-api/build/classes/kotlin/main/io/beaniejoy/dongnecafe/common/security/ApiAuthenticationProvider.class]
└─────┘

Action:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans.

에러로그를 가지고 원인을 파악해봤습니다.

  • @Component로 등록한 ApiAuthenticationProvider에서 PasswordEncoder bean 객체를 주입받고 있는데 이를 위해 SecurityConfig 파일을 참조하게 됩니다.
  • SecurityConfig 클래스에서는 ApiAuthenticationProvider를 주입받고 있습니다. 그렇기 때문에 ApiAuthenticationProvider를 다시 한 번 참조하게 됩니다.
  • ApiAuthenticationProviderPasswordEncoder를 참조하고 있어서 다시 SecurityConfig 파일을 참조하게 됩니다.
  • 이렇게 순환참조가 발생해서 애플리케이션 실행자체가 안되었습니다.

(친절하게 에러로그에서 Action으로 어떻게 해결해야할지도 설명해주고 있습니다.)

 

📌 4. 해결책

이러한 순환참조 고리를 끊기 위한 여러가지 해결책이 있을 것 같습니다.

우선 순환참조되는 Config 파일을 분리시켜서 PasswordEncoder bean 등록을 따로 떼어 놓는 것입니다.
두 번째로는 ApiAuthenticationProvider@Component annotation bean 등록이 아닌 설정파일을 통한 bean 설정하는 방법이 있습니다.

두 번째 방법을 사용해서 한 번 순환참조 문제를 해결해보고자 합니다.

//@Component
class ApiAuthenticationProvider(...) {...)
@Configuration
@EnableWebSecurity
class SecurityConfig {
    //...
    
    @Autowired
    lateinit var userDetailsService: UserDetailsService
    
    //...
    
    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
    
    @Bean
    fun apiAuthenticationProvider(): AuthenticationProvider {
        return ApiAuthenticationProvider(
            userDetailsService = userDetailsService,
            passwordEncoder = passwordEncoder()
        )
    }
    
    @Bean
    fun authenticationManager(): AuthenticationManager {
        val authenticationManager = authenticationConfiguration.authenticationManager as ProviderManager
        authenticationManager.providers.add(apiAuthenticationProvider())
        return authenticationManager
    }
    
    //...
}

@Component를 제거한 ApiAuthenticationProvider를 Config 파일에서 직접 Bean 설정해줍니다. 여기에서 주입시켜야하는 UserDetailsService Bean을 필드 주입받습니다.

이렇게 되면 오로지 SecurityConfig 파일 내에서 ApiAuthenticationProviderPasswordEncoder bean 객체 둘다 관리하고 있기 때문에 순환참조의 고리를 끊을 수 있게 됩니다.

 

📌 5. 정리

위의 예시는 Spring Security를 다루다가 발생했던 순환참조를 들었었는데요. 사실 순환참조는 Spring 프레임워크를 사용하다보면 어떤 상황에서도 발생할 수 있는 내용입니다.

순환참조가 발생하면 애플리케이션 실행시 위에 에러로그처럼 순환참조 발생지점과 어떻게 해결하면 되는지를 친절한 설명이 나올 것입니다. 로그를 보고 순환참조 발생지점을 수정하면 해당 문제는 쉽게 해결할 수 있을 것입니다.