스프링 순환 참조(Circular reference)란?
서로 다른 빈(Bean)이 서로를 참조하면서 스프링이 어떤 빈을 먼저 생성해야 할지 결정하지 못하기 때문에 발생한다.
순환 참조는 DI 상황에 발생한다. DI 방법은 Setter, 필드, 생성자 방식으로 3가지가 있다.
- Setter, 필드 주입 방식
- 필드, Setter 주입 방식에서는 애플리케이션 로딩 중에는 순환 참조 문제가 발생하지 않는다.
- 애플리케이션 로딩 중에 주입하지 않고 실제로 사용하는 시점에 주입을 하기 때문에 해당 메서드를 호출하는 시점에 순환 참조가 발생한다.
- 생성자 주입 방식
- 반면 생성자 주입 방식은 애플리케이션 로딩 중에 순환 참조가 발생한다.
- 빈을 생성하는 시점에 참조하려는 다른 빈을 주입해줘야 하기 때문이다. 순환 참조의 경우 빈A와 빈B가 서로를 참조하다보니 서로가 서로를 주입하는 무한 반복이 발생하는 것이다.
배경
Spring Security를 구현한 후 MemberService와 SecurityConfiguration 간의 순환 참조가 발생했다. MemberService와 SercurityConfiguration이 서로가 서로를 무한히 주입하고 있는 것이다.
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
memberController defined in file [/Users/happy_bin/projects/TravelWithMe-sever/travel-with-me/build/classes/java/main/com/frog/travelwithme/domain/member/controller/MemberController.class]
┌─────┐
| memberService defined in file [/Users/happy_bin/projects/TravelWithMe-sever/travel-with-me/build/classes/java/main/com/frog/travelwithme/domain/member/service/MemberService.class]
↑ ↓
| securityConfiguration defined in file [/Users/happy_bin/projects/TravelWithMe-sever/travel-with-me/build/classes/java/main/com/frog/travelwithme/global/security/config/SecurityConfiguration.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.
아래는 MemberService와 SecurityConfiguration 코드이다.
MemberService
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final CustomAuthorityUtils authorityUtils;
private final JwtTokenProvider jwtTokenProvider;
private final AES128Config aes128Config;
private final RedisDao redisDao;
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
member.passwordEncoding(passwordEncoder);
List<String> roles = createRoles(member);
member.setRoles(roles);
member.setOauthStatus(NORMAL);
// TODO: 이메일 발송 로직 구현 추가 필요
return memberRepository.save(member);
}
public void reissueAccessToken(HttpServletRequest request, HttpServletResponse response) {
String encryptedRefreshToken = jwtTokenProvider.resolveRefreshToken(request);
verifiedRefreshToken(encryptedRefreshToken);
String refreshToken = aes128Config.decryptAes(encryptedRefreshToken);
Claims claims = jwtTokenProvider.parseClaims(refreshToken);
String email = claims.getSubject();
String redisRefreshToken = redisDao.getValues(email);
if (redisDao.validateValues(redisRefreshToken) && refreshToken.equals(redisRefreshToken)) {
Member findMember = findVerifiedMember(email);
CustomUserDetails userDetails = CustomUserDetails.of(findMember);
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String newAccessToken = tokenDto.getAccessToken();
int refreshTokenExpirationMinutes = jwtTokenProvider.getRefreshTokenExpirationMinutes();
redisDao.setValues(refreshToken, newAccessToken,
Duration.ofMinutes(refreshTokenExpirationMinutes));
jwtTokenProvider.accessTokenSetHeader(newAccessToken, response);
} else throw new BusinessLogicException(ExceptionCode.TOKEN_IS_NOT_SAME);
}
public void logout(HttpServletRequest request) {
String encryptedRefreshToken = jwtTokenProvider.resolveRefreshToken(request);
verifiedRefreshToken(encryptedRefreshToken);
String refreshToken = aes128Config.decryptAes(encryptedRefreshToken);
Claims claims = jwtTokenProvider.parseClaims(refreshToken);
String email = claims.getSubject();
String redisRefreshToken = redisDao.getValues(email);
if (!redisDao.validateValues(redisRefreshToken)) {
redisDao.deleteValues(email);
// 로그아웃 시 Access Token Redis 저장 ( key = Access Token / value = "logout" )
String accessToken = jwtTokenProvider.resolveAccessToken(request);
int accessTokenExpirationMinutes = jwtTokenProvider.getAccessTokenExpirationMinutes();
redisDao.setValues(accessToken, "logout", Duration.ofMinutes(accessTokenExpirationMinutes));
}
}
@Transactional(readOnly = true)
public Member findVerifiedMember(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
}
@Transactional(readOnly = true)
public Member findVerifiedMember(String email) {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
}
private void verifiedRefreshToken(String encryptedRefreshToken) {
if (encryptedRefreshToken == null) {
throw new BusinessLogicException(ExceptionCode.HEADER_REFRESH_TOKEN_NOT_EXISTS);
}
}
private void verifyExistsEmail(String email) {
Optional<Member> member = memberRepository.findByEmail(email);
if (member.isPresent()) {
throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS);
}
}
private List<String> createRoles(Member member) {
List<String> roles = authorityUtils.createRoles(member.getRoles().get(0));
if (roles.isEmpty()) {
throw new BusinessLogicException(ExceptionCode.MEMBER_ROLE_DOES_NOT_EXISTS);
}
return roles;
}
}
SecurityConfiguration
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService;
private final AES128Config aes128Config;
private final RedisDao redisDao;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors().configurationSource(corsConfigurationSource())
.and()
.formLogin().disable()
.httpBasic().disable()
.sessionManagement().sessionCreationPolicy(STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.apply(new CustomFilterConfigurer())
.and()
// TODO: 추후 권한 별 페이지 접근제어 설정 예정
.authorizeHttpRequests(authorize -> authorize
.anyRequest().permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
configuration.addExposedHeader("Authorization");
configuration.addExposedHeader("Refresh");
configuration.addAllowedHeader("*");
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager,
jwtTokenProvider, aes128Config, memberService, redisDao);
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenProvider, redisDao);
jwtAuthenticationFilter.setFilterProcessesUrl("/members/login");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new LoginFailurHandler());
builder
.addFilter(jwtAuthenticationFilter)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
}
SecurityConfiguration에서는 MemberService를 참조하게 구현했기 때문에 알고 있었는데 MemberService에서 SecurityConfiguration을 직접 참조하지 않아서 약간 헤멨었다.
어떻게 해결했고 왜 발생했는지 알아보자
원인
MemberService의 코드를 살펴보면 Password를 암호화하기 위해 PasswordEncoder를 의존하고 있다.
@Service
@RequiredArgsConstructor // final이 붙은 필드의 생성자를 자동으로 만들어준다.
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder; // PasswordEncoder 참조
private final CustomAuthorityUtils authorityUtils;
private final JwtTokenProvider jwtTokenProvider;
private final AES128Config aes128Config;
private final RedisDao redisDao;
public Member createMember(Member member) {
verifyExistsEmail(member.getEmail());
member.passwordEncoding(passwordEncoder); // Password 암호화
List<String> roles = createRoles(member);
member.setRoles(roles);
member.setOauthStatus(NORMAL);
return memberRepository.save(member);
}
...
}
SecurityConfiguration을 살펴보면 토큰 발급 역할을 하는 JwtAuthentication에 Member 정보를 전달하기 위해 MemberService를 참조하고 있다.
그리고 PasswordEncoder를 SecurityConfiguration에서 Bean으로 등록해주고 있다는 점이 중요하다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService; // MemberService 참조
private final AES128Config aes128Config;
private final RedisDao redisDao;
...
// SecurityConfiguration 내부에서 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
...
}
위에서 살펴본 상황으로 정리해보자
- SecurityConfiguration은 MemberService를 참조하고 있다.
- MemberService는 PasswordEncoder를 참조하고 있다.
- PasswordEncoder의 Bean은 SecurityConfiguration 내부에서 등록된다.
- 결국 MemberService는 SecurityConfiguration을 참조하게 되면서 순환 참조가 발생한다.
- 해결해보자
해결
PasswordEncoder Bean을 SecurityConfiguration 외부에서 등록되도록 분리해야 한다.
PasswordEncoderConfig 클래스를 만들어 이 클래스에서 PasswordEncoder Bean을 생성하도록 하자
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
PasswordEncoderConfig 생성 후 다시 애플리케이션을 실행해주었다.
여기서 잘 해결되었다면 좋았겠지만 다시 에러가 발생했다! ㅎㅎ…
하지만 콘솔에서 친절하게 설명해준다.
2023-03-31 14:09:53.921 ERROR 16004 --- [ Test worker] o.s.b.d.LoggingFailureAnalysisReporter :
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'passwordEncoder', defined in class path resource [com/frog/travelwithme/global/security/config/SecurityConfiguration.class], could not be registered.
A bean with that name has already been defined in class path resource [com/frog/travelwithme/common/config/PasswordEncoderConfig.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
번역해보면
Description:
클래스 경로 리소스 [com/frog/travel with me/global/security/config/SecurityConfig.class]에 정의된 bean 'passwordEncoder'를 등록할 수 없습니다.
클래스 경로 리소스 [com/frog/travel with me/common/config/PasswordEncoderConfig.class]에 해당 이름의 빈이 이미 정의되어 있으며 재정의가 비활성화되어 있습니다.
Action:
bean 중 하나의 이름을 바꾸거나 spring.main.allow-bean-definition-definition-definition=true를 설정하여 재정의를 활성화하는 것을 고려합니다
PasswordEncoderConfig Bean을 두 곳에서 생성하고 있었다. PasswordEncoderConfig를 만들면서 SecurityConfiguration 클래스에서 PasswordEncoder Bean을 생성하는 메서드를 지워줬어야 했는데 까먹고 지우지 못했다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtTokenProvider jwtTokenProvider;
private final MemberService memberService; // MemberService 참조
private final AES128Config aes128Config;
private final RedisDao redisDao;
...
// 해당 메서드 삭제
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
...
}
SecurityConfiguration 클래스에서 passwordEncoder() 메서드를 삭제해주고 애플리케이션을 다시 실행해보면 아주 깔끔하게 실행된다!