이번 카카오 테크 캠퍼스 활동간에 나는 아래와 같이 인증된 유저 객체를 Presentation layer로 받아오기 위해서 아래와 같은 방식을 사용했다.
/**
* 산책 허락하기 메서드
*/
@PostMapping("walk/{walkerId}/{matchingId}")
public ApiResponse<ApiResponse.CustomBody<Void>> acceptWalk(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable("walkerId") Long userId, @PathVariable("matchingId") Long matchingId)
throws MatchNotExistException, DuplicateNotificationWithWalkException, MemberNotExistException {
walkService.saveWalk(customUserDetails, userId, matchingId);
return ApiResponseGenerator.success(HttpStatus.OK);
}
그 때 카카오 테크 캠퍼스의 같은 팀원분이 아래와 같이 Bean을 통해서 Service Layer에서 사용자 정보를 가져오는 방법에 대해서 이야기를 해주었다.
private String getEmail(){
Authentication loggedInUser = SecurityContextHolder.getContext().getAuthentication();
return loggedInUser.getName();
}
이 때 어느 방식이 우리 프로젝트에 더 적합한 방식이었는지에 대해서 각 방식의 장, 단점을 알아보면서 비교해보고자 한다.
@AuthenticationPrincipal
을 이용한 방식내가 생각한 장점을 뽑는다면 아래와 같다.
코드의 간결화와 간편성이다.
/**
* 산책 허락하기 메서드
*/
@PostMapping("walk/{walkerId}/{matchingId}")
public ApiResponse<ApiResponse.CustomBody<Void>> acceptWalk(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable("walkerId") Long userId, @PathVariable("matchingId") Long matchingId)
throws MatchNotExistException, DuplicateNotificationWithWalkException, MemberNotExistException {
walkService.saveWalk(customUserDetails, userId, matchingId);
return ApiResponseGenerator.success(HttpStatus.OK);
}
인증된 유저 객체를 가져오는 2번 방식과 비교해서, 직접 SecurityContextHolder에 접근해서 유저객체를 가져오지 않고 메서드 파라미터로 주입을 받으니 코드의 간결화 및 간편성 또한 증대된다.
Presentation Layer에서의 직접적인 접근이 편리하다.
Presentation Layer에서부터 메서드 파라미터로 인증된 유저 객체를 받을 수 있기 때문에 Presentation Layer에서 직접 사용하거나, Service 계층에 유저 객체를 내려줄 때 특정 값으로 연산 후 내려줄 수도 있다.
반면 내가 생각한 단점들을 이야기 해보자.
@AuthenticationPrincipal
은 내부적으로 UserDetailsService를 이용해서 DB로부터 유저 객체를 조회 후에 가져오기 때문에 조회 쿼리가 발생한다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
);
return new CustomUserDetails(member);
}
}
단위 테스트, 통합 테스트시 유저 인증 객체를 넣어주는 방식이 불편하다.
@WithUserDetails(value = "yardyard@likelion.org", userDetailsServiceBeanName = "customUserDetailsService", setupBefore = TestExecutionEvent.TEST_EXECUTION
@Test
void get_payment_test() throws Exception {
// given
int matchingId = 1;
// when
ResultActions resultActions = mvc.perform(
get(String.format("/api/payment/%d", matchingId))
);
// console
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : " + responseBody);
// verify
resultActions.andExpect(jsonPath("$.success").value("true"));
}
@WithUserDetails
을 이용해서 테스트시 @AuthenticationPrinciple
에 인증 객체를 넣어줄 수 있다. 허나 이 때 반드시 value에 해당하는 유저가 존재해야하기에, BeforeEach등으로 유저를 미리 저장해야하는 불편함이 존재한다.
@AuthenticationPrincipal
을 사용하면 해당 어노테이션에 의존성을 가지게 되어 특정 스프링 기능에 의존하게 된다.해당 방식을 사용할 경우 추후 Spring에서 해당 어노테이션을 내부적으로 변경하거나, Deprecate할 경우에 높은 Spring 기능 의존으로 인해서 변경에 취약하게 된다.
SecurityContextHolder
에서 가져오는 방법이 방식의 경우 내가 생각한 장점을 뽑는다면 아래와 같다.
내가 User 객체가 필요한 로직에서 유연하게 가져올 수 있다.
private String getEmail(){
Authentication loggedInUser = SecurityContextHolder.getContext().getAuthentication();
return loggedInUser.getName();
}
public List<ChatListResDTO> getChatList() {
Member member = memberRepository.findByEmail(getEmail())
.orElseThrow(() -> new InvalidMemberException(ChatRoomMessageCode.INVALID_MEMBER));
// ...
}
위 코드와 같이 내가 사용하고 싶은 코드에서 직접 SecurityContextHolder에서 인증 유저 객체를 가져오는 메서드를 호출함으로써 특정 메소드에서 필요한 시점에 유연하게 사용자 정보에 접근할 수 있으며, 이를 통해 로직에 따라 다르게 동작하거나 특별한 처리를 할 수 있다.
테스트시 목 객체를 넣기 용이하다.
사전에 SecurityContextHolder에 목 객체를 저장 시켜놓으면 되기 때문에 실제 DB를 거치지 않고, 테스팅을 할 수 있다.
@Test
public void testSomeMethod() {
SecurityTestUtils.setAuthentication("testUser", "ROLE_USER");
// Test logic
SecurityTestUtils.clearAuthentication();
}
이 방식또한 내가 생각한 단점들을 이야기 해보겠다.
1번 방식에 비해서 상대적으로 코드가 지저분해진다.
매번 인증 객체가 필요할 때마다 메서드를 호출해야하며, 인증이 필요한 Service마다 해당 메서드를 구현해야 하기에 코드의 중복이 일어난다.
// ChatService
private String getEmail(){
Authentication loggedInUser = SecurityContextHolder.getContext().getAuthentication();
return loggedInUser.getName();
}
public List<ChatListResDTO> getChatList() {
Member member = memberRepository.findByEmail(getEmail())
.orElseThrow(() -> new InvalidMemberException(ChatRoomMessageCode.INVALID_MEMBER));
// ...
}
그러면 static으로 선언하면 되는거 아니야??
Spring Security의 SecurityContextHolder
는 ThreadLocal
을 사용하여 현재 스레드에 대한 정보를 유지하기 때문에, 정적 메서드를 사용하면 여러 스레드 간에 공유되는 메서드가 되므로 위험하다.
Presentation layer와 Service Layer 간의 결합도가 증가한다.
유저의 정보는 클라이언트와 맞닿아 있는 Presentation Layer에서 접근해 가져오는 것이 결합도를 줄이는 방법이다.
Service Layer에서 유저의 정보를 가져오면, 3 layers의 각 역할을 넘어선 것이라고도 생각한다.
이번 프로젝트에서 내가 구현하고자 한 목표는 성능 최적화적인 부분보다 코드의 간결함이었기 때문에 1번 방식을 택하는게 맞을 것 같다는 생각을 하게 되었다. 반면 만약 극도의 성능 최적화를 하고자하면 직접 Bean에서 유저 객체를 가져오는 2번 방식을 택하는게 맞을 것 같다는 생각을 했다.