[Spring] @AuthenticationPrincipal 커스텀 어노테이션 만들기
이 글은 공부를 하면서 알게 된 내용들을 기록하는 글 입니다. 오류나 고쳐야 할 사항들이 있다면 지적 부탁드립니다!
✅ 커스텀 어노테이션이 필요한 이유
🔥 기존에 사용했던 방법
GitGet 프로젝트에서는 소셜로그인 & JWT 발급 이후에 서비스 이용이 가능합니다.
Spring security의 filterChain을 거치면서 SecurityContext에 인증된 객체 (`UserPrincipal`)에 있는 `User` 객체를 받아와 사용합니다.
`@AuthenticationPrincipal` 어노테이션 덕분에 SecurityContext의 메서드를 직접 호출하지 않아도 되지만, `userPrincipal.getUser()`와 같이 중복된 코드가 발생했습니다.
코드 리뷰를 받았을 때 커스텀 어노테이션을 작성하여 이를 적용하면 좋겠다는 피드백을 받았습니다.
@GetMapping("/items")
public ResponseEntity<ListResponse<ItemResponse>> getItemList(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@RequestParam String category
) {
ItemCategory itemCategory = ItemCategory.findCategory(category);
User user = userPrincipal.getUser();
List<ItemResponse> itemResponses = storeFacade.getItemsByCategory(user, itemCategory);
return ResponseEntity.ok().body(
new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), itemResponses)
);
}
🔥 목표
커스텀 어노테이션을 적용하여 밑과 같이 중복 코드를 없애고, 바로 `user` 객체를 받아와 사용할 수 있도록 합시다!
@GetMapping("/items")
public ResponseEntity<ListResponse<ItemResponse>> getItemList(
@GitGetUser User user,
@RequestParam String category
) {
ItemCategory itemCategory = ItemCategory.findCategory(category);
List<ItemResponse> itemResponses = storeFacade.getItemsByCategory(user, itemCategory);
return ResponseEntity.ok().body(
new ListResponse<>(SUCCESS.getStatus(), SUCCESS.getMessage(), itemResponses)
);
}
✅ @AuthenticationPrincipal 동작 방법
우리는 `@AuthenticationPrincipal` 어노테이션을 확장한 커스텀 어노테이션을 통해 User 객체를 받고자 했습니다.
`@AuthenticationPrincipal`은 어떻게 동작하는 것일까요?
공식문서를 통해 전체적인 흐름을 확인해봅시다!
`@AuthenticationPrincipal`은 `Authentication.getPrincipal()`의 값을 받기 위해 메서드의 파라미터로 사용할 수 있으며,
해당 어노테이션은 Spring security의 `AuthenticationPrincipalArgumentResolver`를 통해 SecurityContext에 있는 Authentication을 받을 수 있습니다.
공식 문서에서는 밑과 같이 설명하고 있습니다.
Allows resolving the Authentication.getPrincipal() using the AuthenticationPrincipal annotation. For example, the following Controller:
...
Will resolve the CustomUser argument using `Authentication.getPrincipal()` from the SecurityContextHolder. If the Authentication or `Authentication.getPrincipal()` is null, it will return null. If the types do not match, null will be returned unless `AuthenticationPrincipal.errorOnInvalidType()` is true in which case a ClassCastException will be thrown.
`AuthenticationPrincipal` 어노테이션을 사용함으로서 `Authentication.getPrincipal()`의 값을 받을 수 있습니다.
...
`Authentication.getPrincipal()`을 통해 SecurityContextHolder에 있는 CustomUser 를 받아올 수 있습니다. `Authentication.getPrincipal()`가 null이라면 null을 반환합니다.
만일 type이 맞지 않은 경우, `AuthenticationPrincipal.errorOnInvalidType()`가 true가 아니라면 null을 반환합니다. (원래는 ClassCastException이 발생)
그 하단에는 커스텀 어노테이션을 만드는 방법도 설명하고 있습니다! 밑의 코드가 공식 문서에서 소개한 방법입니다.
@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal
public @interface CurrentUser {
}
✅ @GitGetUser 커스텀 어노테이션 만들기
만들고 싶은 커스텀 어노테이션 `@GitGetUser`는 여러 Controller에서 사용되었던 `@AuthenticationPrincipal`을 대체할 어노테이션입니다.
@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : user")
public @interface GitGetUser {
}
어노테이션을 생성할 때에는 `@interface`로 선언하여 생성할 수 있고, @Target, @Retention 어노테이션 등을 통해 어노테이션에 대한 설정을 해주어야 합니다.
🔥 Retention policy (@Retention)
@Retention 어노테이션을 통해 Retention policy를 설정할 수 있습니다. Retention을 영어 사전에 검색해보면 보유, 유지라는 결과가 나옵니다.
@Retention은 어노테이션을 얼마나 유지할지에 대해 정의하며, 옵션에는 `SOURCE`, `CLASS`, `RUNTIME`이 있습니다.
Retention 어노테이션에
1) RetentionPolicy.SOURCE
소스 코드에서만 유지되며, 컴파일 타임이 되면 컴파일러에 의해 제거됩니다. 컴파일하기 이전까지만 영향을 미칩니다.
`@Override`, `@Deprecated`, `@Functional`와 같은 어노테이션에서 이 정책을 사용합니다.
2) RetentionPolicy.CLASS
컴파일러에 의해 코드를 컴파일 할 때까지 유지하고, 런타임에는 제거됩니다.
3) RetentionPolicy.RUNTIME
런타임동안 JVM을 통해 어노테이션이 유지됩니다.
🔥 Target (@Target)
어노테이션을 적용할 위치를 정의하는 옵션입니다.
`ElementType` enum의 값을 매개변수(argument)로 받으며, 어노테이션을 적용할 수 있는 위치를 지정할 수 있습니다.
METHOD, FIELD, CONSTUCTOR, PARAMETER, ANNOTATION_TYPE와 같이 어노테이션을 적용하고자하는 곳에 맞는 값을 넣어주면 됩니다.
여러 곳에 적용하고자 한다면 중괄호(`{}`) 안에 값을 여러 개 넣어 적용할 수 있습니다.
🔥 @AuthenticationPrincipal
Spring 공식 문서에서 소개한 방법에 의하면 `@AuthenticationPrincipal` 어노테이션에 별도의 옵션을 넣어주지 않고 있습니다.
하지만 테스트를 돌려본 결과 분명 SecurityContextHolder에 인증 값이 있음에도 불구하고, User의 값이 null이 뜨는 상황이 발생했습니다.
구글링을 해본 결과, `@AuthenticationPrincipal` 어노테이션에 SpEL(Spring Expression Language) 표현식을 통해 어떤 필드의 값을 가져올 것인지 설정을 해줘야 한다고 합니다.
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : user")
만일 현재의 객체(`#this`)가 `anonymousUser` 즉, Spring security에서 인증되지 않은 사용자라면 `null`을 반환하고,
그렇지 않은 경우 즉, Spring security에서 인증된 사용자라면 Authentication.getPrincipal을 통해 `user` 객체를 반환받습니다.
💡SpEL에서 반환 값으로 user을 설정한 이유는, 최종적으로 받고자하는 값이 UserPrincipal의 user 객체이기 때문입니다.
만일 반환받고자하는 값이 User가 아니라 다른 값이라면 해당 값을 넣어주시면 됩니다!
@Getter
public class UserPrincipal implements UserDetails, OAuth2User {
private User user;
private String nameAttributeKey;
private Map<String, Object> attributes;
private Collection<? extends GrantedAuthority> authorities;
...
}
🔥 동작 확인
그럼 우리가 만든 커스텀 어노테이션이 제대로 작동하는지 확인해볼까요?
사용법은 아주 간단합니다.
이전에 @AuthenticationPrincipal을 사용했던 것처럼, 컨트롤러의 메서드에 어노테이션을 작성하면 됩니다.
@GetMapping("/test")
public String test(@GitGetUser User user) {
return "";
}
break point를 걸고 User 객체를 확인해보면 우리가 원하던 객체를 정상적으로 받아옴을 확인할 수 있습니다!
✅ 참고 자료 & 링크
- Spring Boot에서 @AuthUser 커스텀 어노테이션 생성
- @AuthenticationPrincipal 로그인한 사용자 정보 받아오기
- @AuthenticationPrincipal 동작 원리와 사용 예시
- Create your own custom annotation in Spring Boot