Controller 테스트를 하면서 시큐리티의 authentication객체를 매게변수로 받아서 사용하도록 작성한 메서드에서 많은 고민을 하게되었다. 왜냐하면 테스트를 위해 이 객체를 주입해줘야 했기 때문이다. 그래서 어떻게 해결할지 고민을 했다.
1. 먼저 커스텀 Annotation을 만들어서 사용해보자는 결심이 들었다.
1-1. config 패키지에 annotation 패키지를 생성한다.
1-2. annotation패키지에 아래의 클래스들을 생성한다
- InjectAuthentication - 어노테이션 클래스
/**
* 시큐리티 authentication을 주입하는 커스텀 annotation
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface InjectAuthentication {
}
- AOP를 설정할 AuthenticationAspect 클래스
@Aspect
@Component
public class AuthenticationAspect {
@Around("@annotation(com.jinan.profile.config.annotation.InjectAuthentication)")
public Object injectAuthentication(ProceedingJoinPoint joinPoint) throws Throwable {
// 인증 객체 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// ThreadLocal을 사용하여 현재 스레드에 인증 객체 저장
CurrentUserContextHolder.set(authentication);
// 메서드를 실행한다.
Object result = joinPoint.proceed();
// 메서드 실행 후 ThreadLocal에서 인증 객체 제거
CurrentUserContextHolder.clear();
return result;
}
}
- ThreadLocal을 처리할 CurrentUserContextHolder 클래스 생성
/**
* CurrentUserContextHolder라는 ThreadLocal을 사용하여 현재 스레드에 인증 객체를 저장하고, 이를 메서드 내부에서 사용할 수 있도록 한다.
*/
public class CurrentUserContextHolder {
private static final ThreadLocal<Authentication> context = new ThreadLocal<>();
public static void set(Authentication authentication) {
context.set(authentication);
}
public static Authentication get() {
return context.get();
}
public static void clear() {
context.remove();
}
}
1-3. 커스텀 어노테이션 사용방법
- 컨트롤러를 작성하자
// 상단에 이렇게 만들어준 어노테이션을 적는다.
@InjectAuthentication
@PostMapping("/createBoard")
public String createBoard(@RequestBody BoardRequest request) {
// 현재 인증된 사용자의 loginId 가져오기 - 어노테이션이 동작해서 세팅된 값을 가져온다.
Authentication authentication = CurrentUserContextHolder.get();
String loginId = authentication.getName();
// 사용자 로그인id를 사용하여 사용자의 전체 정보 가져오기 (예: 서비스 또는 리포지토리에서)
User user = Optional.ofNullable(userService.findByLoginId(loginId))
.map(User::of)
.orElseThrow(() -> new ProfileApplicationException(ErrorCode.USER_NOT_FOUND));
// 사용자 정보를 request에 추가
request.setUser(user);
boardService.createBoard(request);
return "redirect:/board/list";
}
1-4. 결론
- 이렇게 사용하는게 대체 뭐가 좋은건지 모르겠다. (어노테이션을 적용시키는 방법은 실패라고 생각됨)
- 그냥 매게변수안에 Authentication authentication이나 Principal principal을 적어서 꺼내서 사용하는게 낫지않을까? 라는 생각이 들었다.
- 아직 내가 잘 몰라서 그런것일수도있어서 다른 방법을 찾아보기 시작했다. 나는 컨트롤러 메서드가 매게변수에 시큐리티의 authentication 객체를 받아서 사용하도록 하고싶지가 않았다.
2. 해결방안
2-1. SecurityContextHolder를 직접 컨트롤러에서 사용하는 것은 좋은 설계 방식은 아니다.
- Controller는 주로 요청(request)을 처리하고 응답(response)을 반환하는 역할에 집중하게 설계하는 것이 좋다. SecurityContextHolder와 같은 보안 관련 로직을 컨트롤러에 직접 넣는 것은 관심사의 분리 원칙에 위배될 수 있다.
2-2. 일반적인 설계 원칙에 따르면:
- 컨트롤러는 주로 사용자의 요청을 처리하고 응답을 반환하는 역할에 집중해야 한다.
- 서비스 계층은 비즈니스 로직 및 데이터 처리를 담당한다.
- 도메인 모델은 비즈니스 로직의 핵심이며, 데이터와 관련된 규칙 및 연산을 포함한다.
- 리포지토리는 데이터의 CRUD 작업을 담당한다.
- 따라서, SecurityContextHolder를 사용하여 현재 사용자를 가져오는 로직은 서비스 또는 유틸리티 클래스로 추출하는 것이 좋다. 이렇게 하면 컨트롤러는 보안 관련 로직에 대해 알 필요가 없으며, 요청 처리에만 집중할 수 있다.
2-3. 사용자를 가져오는 클래스 생성
@RequiredArgsConstructor
@Service
public class SecurityService {
private final UserRepository userRepository;
/**
* 지금 접속중인 유저의 이름을 꺼내온다.
*/
public String getCurrentUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
return authentication.getName();
}
return null;
}
/**
* 유저이름으로 유저를 꺼낸다.
*/
public User getCurrentUser() {
String username = getCurrentUsername();
if (username != null) {
return userRepository.findUserByLoginId(username)
.orElseThrow(() -> new ProfileApplicationException(ErrorCode.USER_NOT_FOUND));
}
return null;
}
}
2-4. 컨트롤러에 적용
- securityService.getCurrentUsername(); 이렇게 시큐리티 유틸 클래스를 만들고 가져다 사용하도록 변경했다.
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/board")
@Controller
public class BoardController {
private final SecurityService securityService;
/**
* Action - 게시글 저장기능
* request로 받아온 데이터를 db에 저장한다.
*/
@PostMapping("/createBoard")
public String createBoard(@RequestBody BoardRequest request) {
String loginId = securityService.getCurrentUsername();
User user = Optional.ofNullable(userService.findByLoginId(loginId))
.map(User::of)
.orElseThrow(() -> new ProfileApplicationException(ErrorCode.USER_NOT_FOUND));
request.setUser(user);
boardService.createBoard(request);
return "redirect:/board/list";
}
}
결론적으로 나는 서비스 클래스를 따로 만들어서 의존성을 주입해서 사용하는 방식을 선택했다. 이렇게 함으로써 테스트 코드를 짜는것이 많이 편해졌다.
2023.08.17 - [Spring 테스트코드/기초 지식] - Spring test - 코드 작성중 만난 트러블 슈팅(서비스)
반응형
'Spring > 테스트 코드' 카테고리의 다른 글
주니어 개발자의 테스트 코드 이해하기 (2) | 2023.12.22 |
---|---|
[스프링, 스프링 부트] Spring test - when()에서 발생한 에러 (0) | 2023.08.22 |
Spring 서비스 테스트 중 발견한 NPE 해결기 (0) | 2023.08.17 |
[스프링, 스프링 부트] Spring test - service 테스트에서 만난 오류 (0) | 2023.08.16 |
[스프링, 스프링 부트] Spring test - Mock에 대한 이해 (0) | 2023.08.09 |