@WithSecurityContext로 @PreAuthorize가 적용된 메서드 테스트하기

양말고양이·2025년 1월 14일

프로젝트 이슈 🐟

목록 보기
4/7

지난 포스팅에서는 인가 로직에서 @PreAuthorize를 사용하도록 리팩토링했습니다.

이번에는 @PreAuthorize를 적용한 메서드를 어떻게 테스트했는지 정리해보도록 하겠습니다.




1. 테스트 방식

@PreAuthorize에서 이용하고 있는 EntityAccessHandler에서는,
현재 로그인한 사용자의 userId와 엔티티의 소유자의 userId를 비교하는 로직이 있습니다.

그리고 현재 로그인한 사용자의 userId를 가져오기 위해서는
SecurityContextHolderSecurityContextAuthenticationPrincipal을 이용합니다.

따라서 SecurityContext에 사용자의 인증 정보를 주입하여 테스트를 진행해야겠다고 생각했습니다.


그 방법으로 SecurityContext를 직접 mocking할 지,

@WithMockUser, @WithUserDetails, @WithSecurityContext와 같은 어노테이션을 사용할 지 고민이 됐습니다.


직접 해보자 싶어 SecurityContextmocking하는 방식으로 먼저 테스트를 진행해보았는데, 전체적으로 코드가 복잡해졌고 중복 코드도 많이 발생했습니다.

따라서 이번에는 어노테이션을 이용해 좀 더 간결하게 통합 테스트를 진행해보려고 합니다.


또한 인증 로직에서 UserDetails를 구현하지 않고 Authentication 객체를 커스텀하여 사용하고 있기 때문에,

테스트할 때 @WithMockUser, @WithUserDetails가 아닌 @WithSecurityContext를 사용했습니다.




2.@WithSecurityContext 사용하기

@WithSecurityContext는 커스텀 SecurityContext를 설정할 때 사용하는 어노테이션입니다.

일종의 factory를 만들어 보안 컨텍스트를 설정하는 방식으로 구현합니다.

2.1 커스텀 어노테이션 @WithTestUser

우선 테스트에서 활용할 커스텀 어노테이션을 만들어 줘야 합니다.

저는 WithTestUser라는 이름으로 만들었습니다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTestUserSecurityContextFactory.class)
public @interface WithTestUser {

}

우선 이렇게만 만들어주고, 어노테이션을 처리해줄 WithSecurityContextFactory를 만들겠습니다.


2.2 WithTestUserSecurityContextFactory

이 클래스를 구현하기 위해서는 WithSecurityContextFactory<A extends Annotation> 인터페이스를 implements 해줘야 합니다.

그 후 아까 만든 어노테이션을 파라미터로 받는 createSecurityContext 를 오버라이딩해야 합니다.

public class WithTestUserSecurityContextFactory implements WithSecurityContextFactory<WithTestUser> {
	@Override
	public SecurityContext createSecurityContext(WithTestUser annotation) {
		return null;
	}
}

이 메서드의 자세한 처리는 @WithMockUser의 팩토리 클래스인 WithMockUserSecurityContextFactory를 좀 참고했는데,

결국 실제로 동작하는 것과 비슷하게 SecurityContextAuthentication 객체를 세팅해주면 될 것 같았습니다.


Authentication 객체를 만들 때 userId (principal)token (credientials)이 필요한데,

이건 테스트할 때 어노테이션에서 전달해주면 좋을 것 같아 위에서 만든 어노테이션 클래스에 속성을 추가해줬습니다.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTestUserSecurityContextFactory.class)
public @interface WithTestUser {
	long userId() default 0L;
	String token() default "";
}

왜 어노테이션 속성에는 long을 써야하는가?

  • long
    • Primitive 타입이기 때문에 컴파일 타임에 고정된 값을 저장할 수 있음 → 허용
  • Long
    • Wrapper class, 객체이므로 런타임에 생성됨 → 허용하지 않음
  • String
    • 불변 객체이기 때문에 컴파일 타임에 고정된 값을 저장할 수 있음 → 허용

결과적으로 팩토리 클래스는 아래와 같이 만들어줬습니다.

public class WithTestUserSecurityContextFactory implements WithSecurityContextFactory<WithTestUser> {
	@Override
	public SecurityContext createSecurityContext(WithTestUser annotation) {
		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();

		Long userId = annotation.userId();
		String token = annotation.token();

		Authentication authentication = AuthenticationToken.getAuthentication(userId, token);
		securityContext.setAuthentication(authentication);
		return securityContext;
	}
}

2.3 테스트 코드에 적용하기

이제 테스트 코드에 적용해보겠습니다.

테스트 대상은 아래의 getUserInfo로, 사용자의 정보를 가져오는 간단한 기능이며 자기 자신의 정보만 요청할 수 있기 때문에 @PreAuthorize를 이용했습니다.


@PreAuthorize("@userAccessHandler.isOwner(#userId)")
public UserDetailResponse getUserInfo(Long userId) {
	User user = getUserById(userId);
	return UserDetailResponse.of(user);
}

SecurityContext에 사용자 정보 주입을 해준 후 테스트를 하는 것이기 때문에
테스트 클래스에 @SpringBootTest 어노테이션을 붙여 전체 애플리케이션 컨텍스트를 로드했습니다.

코드는 아래와 같이 작성했습니다.


@SpringBootTest
public class UserServiceIntegrationTest {

	@Autowired
	private UserService userService;

	@MockBean
	private UserRepository userRepository;
    .
    .
    .
    @Test
	@WithTestUser(userId = 1L, token = "TOKEN")
	@DisplayName("사용자 정보 조회 실패_다른 사용자의 정보를 요청할 경우")
	void getUserInfo_otherUser_throwAuthorizationDeniedException() {
		//given
		Long userId = 2L;

		//when & then
		assertThrows(AuthorizationDeniedException.class, () -> userService.getUserInfo(userId));
	}

1번 사용자로 로그인했을 때 2번 사용자에 대해 정보 조회를 요청할 경우 예외가 발생하는지 검증합니다.


SecurityContext를 직접 mocking했을 때보다 훨씬 코드가 깔끔해져 가독성이 좋아졌습니다 🧤🧼




다음 포스팅은

당분간은 테스트 코드 관련 포스팅이 많을 것 같습니다.

참고

https://docs.spring.io/spring-security/reference/servlet/test/method.html#test-method-withsecuritycontext

https://velog.io/@onetuks/WithMockUser-WithUserDetails-WithSecurityContext#1-securitycontextholder-%EC%95%88-%EC%93%B0%EB%8D%94%EB%9D%BC%EB%8F%84

profile
백엔드 개발자입니다

0개의 댓글