[Spring Boot] DefaultOAuth2UserService 의 super method 의 mocking 문제

이홍준·2023년 8월 22일
0

Trouble Shooting

목록 보기
3/3

Google OAuth 관련 기능을 구현을 하는데 있어서 테스트 코드를 작성하는 중 문제가 생겼다. 클린 아키텍처 방식으로 기본적으로 port와 같은 인터페이스로 접근하기 때문에 mocking하는 것이 매우 편해서 좋았다. 그러나 외부 라이브러리를 사용하다보면 원래 계획했던 규칙을 적용하기 애매한 문제들이 많았다. 특히 인증관련에서 경계가 애매한 부분들이 많았다. mocking 하는데 힘들었던 부분을 해결하는 과정에 대해서 알아 보도록 한다.

문제 상황

@PersistenceAdapter
@RequiredArgsConstructor
public class OAuth2UserPersistenceAdapter extends DefaultOAuth2UserService {
    private final SpringDataUserRepository userRepository;
     // OAuth 에서 응답 받은 유저 정보 추출
    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); // 단위 테스트 중 NPE 발생
        // ... 기타 로직
    }
}


@DisplayName("CustomDefaultOAuth2UserServiceTest 테스트")
@ExtendWith(MockitoExtension.class)
class OAuth2UserPersistenceTest {
    @InjectMocks
    private OAuth2UserPersistenceAdapter oAuth2UserPersistenceAdapter;
    @Mock
    private SpringDataUserRepository userRepository;

    @DisplayName("OAuth user 조회 테스트- 존재하는 계정일 경우")
    @Test
    public void loadUser_existUser_test(){
        // Given
        DefaultOAuth2UserService defaultOAuth2UserService = mock(DefaultOAuth2UserService.class);
        OAuth2User oAuth2User = mock(OAuth2User.class);
        OAuth2UserRequest request = mock(OAuth2UserRequest.class);
        UserJpaEntity userJpaEntity = mock(UserJpaEntity.class);
        when(defaultOAuth2UserService.loadUser(request)).thenReturn(oAuth2User); 
       
        // When
        oAuth2UserPersistenceAdapter.loadUser(request);

        // Then
        verify(userRepository, times(1)).findByEmail(any());

    }
}

기존 방식은 DefaultOAuth2UserService을 상속받은 클래스 형태로 loadUser라는 메소드를 직접 상속받아 사용하는 방식으로 했었다. 구현할 때는 잘 몰랐지만 막상 단위테스트를 작성해보니 모킹하는데 큰 어려움이 있었다.

DefaultOAuth2UserService는 Spring Bean 으로 등록하지 않았기 때문에 모킹할수 없을 뿐더러 (인스턴스를 매칭할 수 없다) 그리고 그대로 상속받는 방식이기 때문에 테스트를 하는데 독립적으로 수행하는데 어려움이 있었다.

왜 어려운지 자세히 직접 라이브러리 코드로 보면 이해하기 쉬울 것이다.

public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

	private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

	private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

	private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

	private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<Map<String, Object>>() {
	};

	private Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();

	private RestOperations restOperations;

	public DefaultOAuth2UserService() {
		RestTemplate restTemplate = new RestTemplate();
		restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
		this.restOperations = restTemplate;
	}

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		Assert.notNull(userRequest, "userRequest cannot be null");
		if (!StringUtils
				.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
					"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
				.getUserNameAttributeName();
		if (!StringUtils.hasText(userNameAttributeName)) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
					"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
		ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
		Map<String, Object> userAttributes = response.getBody();
		Set<GrantedAuthority> authorities = new LinkedHashSet<>();
		authorities.add(new OAuth2UserAuthority(userAttributes));
		OAuth2AccessToken token = userRequest.getAccessToken();
		for (String authority : token.getScopes()) {
			authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
		}
		return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
	}
...
}

위와 같이 기존 라이브러리에 있던 DefaultOAuth2UserService 클래스에 있는 loadUser 함수에서 사용하는 요소들을 가정하고, Bean일 경우 mocking을 해줘야 하는데 배보다 배꼽이 더 크다는 생각이 들었다.

그리고 직접 구현 하자니 테스트를 위해서 구현을 희생할 순 없다고 생각했다. 그래서 생각했던 방법이 있었다.

해결 과정

@PersistenceAdapter
@RequiredArgsConstructor
public class OAuth2UserPersistenceAdapter implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final SpringDataUserRepository userRepository;
    private final DefaultOAuth2UserService delegate; // 기존의 Super Class
    // OAuth 에서 응답 받은 유저 정보 추출
    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = delegate.loadUser(oAuth2UserRequest);
        GoogleUser userInfo = mapToGoogleUser(oAuth2User.getAttributes());
        String email = userInfo.getEmail();
        Optional<UserJpaEntity> userJpaEntity = userRepository.findByEmail(email);
        Long userId = userJpaEntity.map(UserJpaEntity::getId)
                .orElseGet(() -> userRepository.save(mapToJpaEntity(userInfo)).getId());
        List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
        authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
        return UserDetailsImpl.builder()
                .username(userId.toString())
                .attributes(oAuth2User.getAttributes())
                .authorities(authorityList)
                .build();
    }
@Configuration
public class WebSecurityConfig{
//...
	private final OAuth2UserPersistenceAdapter oAuth2UserPersistenceAdapter;
//...
	@Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
			http
			//...
			.oauth2Login()
			.userInfoEndpoint()
			.userService(oAuth2UserPersistenceAdapter)
			// ...
			return http.build();
	}
}

상속대신에 구성(Composition)하는 방법을 선택해 보았다. 부모클래스 DefaultOAuth2UserService의 loadUser를 delegate의 방식으로 기능을 사용해보았다. 그리고 Security쪽에서 config에 OAuth2UserService를 등록하기 위해서 해당 인터페이스를 상속받아야만 한다. (DefaultOAuth2UserService의 인터페이스와 같음)

@Configuration
public class OAuth2Config {
    @Bean
    public DefaultOAuth2UserService defaultOAuth2UserService(){
        return new DefaultOAuth2UserService();
    }
}

OAuth2UserPersistenceAdapter로 의존성 주입을 위해 DefaultOAuth2UserService의 Bean도 등록해준다. (SecurityFilterChain와 같은 클래스에서 정의하면 순환참조 발생으로 다른 클래스로 빼주어야 한다.)

조치 결과


@DisplayName("CustomDefaultOAuth2UserServiceTest 테스트")
@ExtendWith(MockitoExtension.class)
class OAuth2UserPersistenceTest {
    @InjectMocks
    private OAuth2UserPersistenceAdapter oAuth2UserPersistenceAdapter;
    @Mock
    private SpringDataUserRepository userRepository;
    @Mock
    private DefaultOAuth2UserService delegate;
    @DisplayName("OAuth user 조회 테스트- 존재하는 계정일 경우")
    @Test
    public void loadUser_existUser_test(){
        // Given
        OAuth2User oAuth2User = mock(OAuth2User.class);
        OAuth2UserRequest request = mock(OAuth2UserRequest.class);
        UserJpaEntity userJpaEntity = mock(UserJpaEntity.class);
        when(delegate.loadUser(request)).thenReturn(oAuth2User);
        when(userRepository.findByEmail(any())).thenReturn(Optional.of(userJpaEntity));

        // When
        oAuth2UserPersistenceAdapter.loadUser(request);

        // Then
        verify(userRepository, times(1)).findByEmail(any());

    }
    @DisplayName("OAuth user 조회 테스트-가입할 경우 경우")
    @Test
    public void loadUser_test(){
        // Given
        OAuth2User oAuth2User = mock(OAuth2User.class);
        OAuth2UserRequest request = mock(OAuth2UserRequest.class);
        UserJpaEntity userJpaEntity = UserJpaEntity.builder()
                .id(1L)
                .position(Position.EMPLOYEE.getDepth())
                .email("zxc@google.com")
                .password("zxczxczx")
                .build();
        when(delegate.loadUser(request)).thenReturn(oAuth2User);
        when(userRepository.findByEmail(any())).thenReturn(Optional.empty());
        when(userRepository.save(any())).thenReturn(userJpaEntity);
        // When
        oAuth2UserPersistenceAdapter.loadUser(request);

        // Then
        verify(userRepository, times(1)).findByEmail(any());

    }
}

상속보다는 구성!

결과적으로 외부의 의존성을 줄임으로써 코드의 품질도 높아질 뿐더러 단위테스트에도 간단해진 만큼 두마리 토끼를 잡는데 성공하는 결과를 낳게 되었다.

결론

이 과정에서 나는 느낀점이 객체지향 프로그래밍에서 상속이라는 개념이 중복도 많이 줄여주고 좋다고 생각했었다. 하지만 그만큼 책임이 따르는 부분들이 있다는 것을 알게 되었다. 부모클래스와 자식클래스간의 결합도가 높아짐으로써 테스트 및 유지보수하는데 있어서 문제점을 겪고 나니 상속을 최대한 쓰려고 했던 습관을 많이 버리게 되는 계기가 되었다. “Composition over Inheritance” 이라는 말이 조금 나에게는 추상적이라고 느꼈는데, 뼈저리게 많이 느꼈다. 상속이라는 옵션에 있어서 적절하게 섞어서 사용하는 기준에 대해서 판별하는데 큰 도움이 되었다고 생각한다.

profile
I'm a web developer.

0개의 댓글