Bean 순환 참조 문제 해결기

onyoo·2024년 3월 13일
0

Spring-Boot

목록 보기
5/6

순환참조란?

서로 다른 빈들이 참조를 맞물리게 주입되면서 생기는 현상이다.

A라는 빈을 생성하기 위해서 B가 필요한데 B라는 빈을 생성하기 위해서 A가 필요한 경우가 바로 순환참조다.

즉, 어떠한 스프링 빈을 먼저 만들어야 할 지 결정할 수 없게되는 상황이라고 할 수 있다.

순환참조가 발생하는 경우

순환참조는 의존성을 주입하는 상황에서 스프링이 어느 스프링빈을 먼저 생성할지 결정하지 못해 발생하는 상황이다.
정리하자면 순환 참조 문제도 의존성 주입을 하는 방법에 따라 순환참조가 일어나는 시점이 다르다.

생성자 주입 방식

@Component
public class BeanA {
	private BeanB beanB;

	public void BeanA(BeanB beanB){
		this.beanB = beanB;
	}
}

@Component
public class BeanB {
	private BeanA beanA;

	public void BeanB(BeanA beanA){
		this.beanA = beanA;
	}
}

위와 같은 상황에서는

BeanA -> BeanB
BeanB -> BeanA

와 같은 관계가 발생하기 때문에 무한반복이 발생한다.

필드 및 Setter 주입 방식

@Slf4j
public class BeanA {
	@Autowired
	private BeanB beanB;

	public void run(){
		beanB.run();
	}

	public void call(){
		log.info("called BeanA");
	}
}

@Component
@Slf4j
public class BeanB {
	@Autowired
	private BeanA beanA;

	public void run(){
		log.info("Called BeanB");
		beanA.call();
	}
}
//필드 주입 방식
@Component
@Slf4j
public class BeanA {
	private BeanB beanB;

	@Autowired
	public void setBeanB(BeanB beanB){
		this.beanB = beanB;
	}

	public void run(){
		beanB.run();
	}

	public void call(){
		log.info("called BeanA");
	}
}

@Component
@Slf4j
public class BeanB {
	private BeanA beanA;

	@Autowired
	public void setBeanA(BeanA beanA){
		this.beanA = beanA;
	}

	public void run(){
		log.info("Called BeanB");
		beanA.call();
	}
}
//Setter 주입 방식

두가지 의존성 주입 방식은 애플리케이션 구동 당시에는 순환참조가 발생하지 않는다.

이 두가지 방식은 필요한 의존성이 없을 경우에는 null상태를 유지하고 실제로 사용하는 시점에 주입하기 때문이다.
그러므로 두가지 방식은 실질적으로 해당 메서드를 호출하는 시점에서 순환참조 문제가 발생할것이다.

그렇다면 내가 작성한 코드는 어디에서 문제가 발생했는가 ?

에러코드

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

┌─────┐
|  jwtFilter defined in file [C:\Users\SSAFY\Documents\GitHub\S10P22A702\backend\build\classes\java\main\com\dango\dango\global\filter\JwtFilter.class]
↑     ↓
|  inMemoryUserDetailsManager defined in class path resource [org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.class]
↑     ↓
|  securityConfig defined in file [C:\Users\SSAFY\Documents\GitHub\S10P22A702\backend\build\classes\java\main\com\dango\dango\global\config\SecurityConfig.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.

다음과 같은 순환참조 메세지가 떳고,친절하게도 어떻게 순환참조를 끊어야 하는지도 알려주었다.

1차 문제 해결

검색을 한 결과 passwordEncoder와 관련하여 bean 설정이 문제가 많았고 해당 부분으로 문제를 해결하였다.
하지만 이는 근본적인 해결이 아니었다.

//securityConfig
package com.dango.dango.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.dango.dango.global.filter.JwtFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	private final JwtFilter jwtFilter;
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
		httpSecurity
			.sessionManagement(
				(session)-> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			)
			.csrf((csrf)->csrf.disable())
			.authorizeHttpRequests(
				auth -> auth
					.requestMatchers(HttpMethod.POST,"/api/user/register/**").permitAll()
					.requestMatchers(HttpMethod.POST,"/api/user/login/**").permitAll()
					.anyRequest().authenticated()
			)
			.addFilterBefore(
				jwtFilter, UsernamePasswordAuthenticationFilter.class
			);

		return httpSecurity.build();
	}
	@Bean
	public PasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	}
}
//jwt filter

package com.dango.dango.global.filter;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
	private final UserDetailsService userDetailsService;

	@Value("${jwt.secret}")
	private String SecretKey;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		String token = extractToken(request);

		if(token != null){
			if(validateToken(token)){
				Claims claims = extractClaims(token);
				String username = (String)claims.get("username");
				request.setAttribute("username",username);
				UserDetails userDetails = userDetailsService.loadUserByUsername(username);
				Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(),userDetails.getPassword(),userDetails.getAuthorities());
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}else{
				response.setStatus(HttpStatus.UNAUTHORIZED.value());
				return;
			}
		}
		filterChain.doFilter(request,response);
	}

	private String extractToken(HttpServletRequest request){
		String bearerToken = request.getHeader("Authorization");
		if(bearerToken != null && bearerToken.startsWith("Bearer")) return bearerToken.substring(7);
		return null;
	}

	private boolean validateToken(String token){
		try{
			Jwts.parser().setSigningKey(SecretKey).parseClaimsJws(token);
		}
		catch (Exception e){
			throw new IllegalArgumentException("유효하지 않은 토큰입니다");
		}
		return true;
	}

	private Claims extractClaims(String token){
		return Jwts.parser().setSigningKey(SecretKey).parseClaimsJws(token).getBody();
	}
}
//UserDetailsServiceImpl
package com.dango.dango.domain.user.service;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.dango.dango.domain.user.entity.User;
import com.dango.dango.domain.user.repository.UserRepository;

public class UserDetailsServiceImpl implements UserDetailsService {

	private final UserRepository userRepository;

	public UserDetailsServiceImpl(UserRepository userRepository) {
		this.userRepository = userRepository;
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user = userRepository.findByUsername(username).orElseThrow(
			()->
				new UsernameNotFoundException(
					"해당 이름의 유저를 찾지 못햇습니다")
		);
		return user;
	}
}

위의 코드가 문제가 발생한 코드이며, 나는 SecurityConfig에 있던 PasswordEncoder를 AppConfig로 따로 빼어서 문제를 해결해주었다.

여기서 발생한 의문은 다음과 같다.
나는 password encoder에 의존하여 코드를 작성하지 않았고, 검색해본 결과들의 경우 service에 password encoder를 직접적으로 사용하여서 문제가 발생했다.
즉,해결한 방법이 제대로 된 것 같지 않다는 것이다.

2차 문제 해결

위의 코드를 보고 이상한 점이 없나? 바로, UserDetailsServiceImpl에 @Service 어노테이션이 없다.
JwtFilter에서 의존했던 UserDetailsSerivce는 시큐리티에 구성된 Interface를 참조하고 있었다..
내가 직접 구현한 Impl은 어노테이션이 없어서 bean으로 등록되지 않아 interface를 참조해서 오고있었고 구현체가 없어서 시큐리티 자체 구현체인 InMemoryUserDetailsManager까지 끌어오게 되었다.


//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.boot.autoconfigure.security.servlet;

import java.util.List;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.ConfigurationCondition.ConfigurationPhase;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.util.StringUtils;

@AutoConfiguration
@ConditionalOnClass({AuthenticationManager.class})
@Conditional({MissingAlternativeOrUserPropertiesConfigured.class})
@ConditionalOnBean({ObjectPostProcessor.class})
@ConditionalOnMissingBean(
    value = {AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, AuthenticationManagerResolver.class},
    type = {"org.springframework.security.oauth2.jwt.JwtDecoder"}
)
public class UserDetailsServiceAutoConfiguration {
    private static final String NOOP_PASSWORD_PREFIX = "{noop}";
    private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
    private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

    public UserDetailsServiceAutoConfiguration() {
    }

    @Bean
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) {
        SecurityProperties.User user = properties.getUser();
        List<String> roles = user.getRoles();
        return new InMemoryUserDetailsManager(new UserDetails[]{User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
    }

// 생략

UserDetailsServiceAutoConfiguration에서 InMemoryUserDetailsManager를 Bean으로 생성하는 코드를 보면 그 답이 나온다.

@Bean
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) {
        SecurityProperties.User user = properties.getUser();
        List<String> roles = user.getRoles();
        return new InMemoryUserDetailsManager(new UserDetails[]{User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
    }

우리가 주목할 코드는 public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) 이다.

InMemoryUserDetailsManager를 생성할 때,바로 PasswordEncoder가 필요하기 때문이다..

즉,UserDetailsService를 직접 구현하지 않고 사용할 경우 password encode를 참조하고 있는 InMemoryUserDetailsManager에 선언되어 있는 UserDetailsService를 사용하기 때문에 순환참조 문제가 발생하게 되는 것이다.

이래서 password encoder를 분리하여 주니 문제가 해결된 것이지만. 근본적으로는 UserDetailsService를 직접 구현한 구현체를 참조하게 만드는 것이 해결책이라고 볼 수 있다.

수정된 코드는 다음과 같다.

package com.dango.dango.domain.user.service;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.dango.dango.domain.user.entity.User;
import com.dango.dango.domain.user.repository.UserRepository;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

	private final UserRepository userRepository;

	public UserDetailsServiceImpl(UserRepository userRepository) {
		this.userRepository = userRepository;
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user = userRepository.findByUsername(username).orElseThrow(
			()->
				new UsernameNotFoundException(
					"해당 이름의 유저를 찾지 못햇습니다")
		);
		return user;
	}
}
package com.dango.dango.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.dango.dango.global.filter.JwtFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	private final JwtFilter jwtFilter;
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
		httpSecurity
			.sessionManagement(
				(session)-> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			)
			.csrf((csrf)->csrf.disable())
			.authorizeHttpRequests(
				auth -> auth
					.requestMatchers(HttpMethod.POST,"/api/user/register/**").permitAll()
					.requestMatchers(HttpMethod.POST,"/api/user/login/**").permitAll()
					.anyRequest().authenticated()
			)
			.addFilterBefore(
				jwtFilter, UsernamePasswordAuthenticationFilter.class
			);

		return httpSecurity.build();
	}
	@Bean
	public PasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	}
}
package com.dango.dango.global.filter;

import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Date;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.dango.dango.domain.user.service.UserDetailsServiceImpl;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
	private final UserDetailsService userDetailsService;

	@Value("${jwt.secret}")
	private String SecretKey;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		String token = extractToken(request);
		if(token != null){
			try{
				if(validateToken(token)){
					Claims claims = extractClaims(token);
					String username = (String)claims.get("username");
					request.setAttribute("username",username);
					UserDetails userDetails = userDetailsService.loadUserByUsername(username);
					Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(),userDetails.getPassword(),userDetails.getAuthorities());
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}else{
					throw new IllegalArgumentException("유효하지 않은 토큰입니다");
				}
			}catch (IllegalArgumentException e){
				response.setHeader("Expired-Token","true");
				//토큰의 기간이 만료되었음을 알려주자
			}
		}
		filterChain.doFilter(request,response);
	}

	private String extractToken(HttpServletRequest request){
		String bearerToken = request.getHeader("Authorization");
		if(bearerToken != null && bearerToken.startsWith("Bearer")) return bearerToken.substring(7);
		return null;
	}

	private boolean validateToken(String token){
		try{
			Claims claims = Jwts.parser().setSigningKey(SecretKey).parseClaimsJws(token).getBody();
			Date expiration = claims.getExpiration();

			if(expiration.before(new Date())){
				// 액세스 토큰이 만료되었을 경우
				throw new IllegalArgumentException("토큰이 만료되었습니다");
			}
		}
		catch (Exception e){
			return false;
		}
		return true;
	}

	private Claims extractClaims(String token){
		return Jwts.parser().setSigningKey(SecretKey).parseClaimsJws(token).getBody();
	}
}

후기

트러블 슈팅 중에서 제일 힘들고 어려웠던 것 같다..
뭔가 문제가 해결된 것 같지는 않은데 잘 모르겠고. 이곳저곳 물어보고 다니다가 해당 문서를 참고해보라는 말에 해답을 찾게되었다.

이번 트러블 슈팅을 계기로 스프링부트의 경우 내가 사용하는 라이브러리의 내부 구조와 의존 구조를 모르기 때문에 더욱더 신중하게 사용해야한다는 것을 알았고.

코드 작성에 있어서 사소한 실수가 큰 변화를 만들 수 있다는 것 또한 깨닫게 되었다.

참고 문서

InMemoryUserDetailsManager

UserDetailsServiceAutoConfiguration

관련 블로그

profile
반갑습니다 ! 백엔드 개발 공부를 하고있습니다.

0개의 댓글