커스텀 어노테이션이 필요한 상황
지금 구현하고 있는 프로젝트에서는 거의 대부분의 기능을 소셜 로그인 후 사용 가능하다. 따라서 거의 모든 API에서 JWT 토큰 인증 후 토큰에 있는 정보를 가지고 member의 정보(id, email 등등)을 추출하는 로직이 필요한데, 이를 컨트롤러에서 중복되는 코드를 계속 작성하기에는 너무 지저분하기도 하고, 깔끔하게 어노테이션 하나로 선언적으로 표현하고 싶어서 커스텀 어노테이션인 @AuthUser를 만들기로 했다.
@AuthUser 어노테이션
@AuthUser는 여러 도메인에서 글로벌하게 사용할 어노테이션이므로, ./global/auth 아래에 만들어 주었다. 코드는 아래와 같다. (참고로 interface 앞의 @는 오타가 아니라, 어노테이션 타입이라는 뜻이다.)
@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Parameter(hidden = true)
/* 인증 객체 정보를 가지고 다니는 커스텀 어노테이션, AuthDetail의 필드인 member를 가져온다. */
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")
public @interface AuthUser {
}
각 어노테이션들의 의미는 다음과 같다.
@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}): @Target은 메타 어노테이션으로, 다른 어노테이션을 추가하는 데에만 사용된다. ElementType을 명시하면 해당 ElementType에서만 커스텀 어노테이션을 적용 가능하다. 여기서는 Controller의 파라미터로 받을 때 @AuthUser를 사용해야 하므로, ElementType을PARAMETER로 명시했다. 그리고 그냥 어노테이션 자체로 사용하고 싶을 때가 있을 수 있으므로ANNOTATION_TYPE도 같이 명시해주었다. 이 두 범위 이외에서 활용되면 에러가 발생한다.@Retention(RetentionPolicy.RUNTIME): 런타임에도 유지된다는(retention) 뜻이다. 3가지 타입이 있는데, 소스 코드 수준에서만 머물고 런타임에는 삭제되는SOURCE,.class파일에 기록되지만 런타임 중에는 삭제되는CLASS(디폴트 설정), 런타임 중에 프로그램에서 엑세스 가능한RUNTIME이 있다. 우리는 실제로 서비스에서 이 어노테이션을 사용해야 하므로RetentionPolicy.RUNTIME으로 명시해야 한다.@Parameter(hidden = true): 필수는 아니지만, 나는 지금 Swagger UI를 사용해서 API 문서를 만들고 있는데, 이때@AuthUser가 필수 파라미터로 표시되는 것을 막기 위해서hidden = true옵션을 사용했다.
@AuthenticationPrincipal에 대해
내가 구현하고자 하는 어노테이션에서 가장 중요한 부분이다. 나는 JWT 토큰을 통해서 유저 인증이 완료되면 토큰에 일치하는 member 객체를 바로 받고 싶다. 이를 위해서 @AuthenticationPrincipal 어노테이션을 사용해야 한다. 이 어노테이션은 스프링 시큐리티의 AuthenticationPrincipalArgumentResolver 클래스를 활용해서 SecurityContextHolder에 접근할 수 있다. 즉 인증 객체에 접근해서 값을 가져올 수 있는 것이다. 공식 문서에서는 다음과 같이 설명하고 있다.
Will resolve theCustomUserargument usingAuthentication.getPrincipal()from theSecurityContextHolder. If theAuthenticationorAuthentication.getPrincipal()isnull, it will returnnull.
@AuthenticationPrincipal어노테이션은SecurityContextHolder에서Authentication.getPrincipal()을 통해서CustomUser인수를 가져올 수 있습니다. 만약Authentication또는Authentication.getPrincipal()이null이라면null을 반환합니다.
@AuthenticationPrincipal 어노테이션 뒤에 붙는 (expression = "#this == 'anonymousUser' ? null : member")은 SpEL(Spring Expression Language) 표현식인데, 만약 현재의 객체 #this가 스프링 시큐리티에서 인증되지 않은 사용자를 나타내는 특별한 값인 anonymousUser라면, null을 반환하고, 그렇지 않을 경우에는 Authentication.getPrincipal()를 통해서 가져올 수 있는 UserDetails 객체의 member 필드를 가져올 수 있다.
AuthDetails.java
나는 UserDetails를 구현한 클래스로 AuthDetail이 있다. 이 안에 내가 넣고 싶은 사용자 객체를 Member로 명시했기 때문에 @AuthenticationPrincipal 어노테이션이 member 객체를 가져올 수 있다.
@Data
@RequiredArgsConstructor
public class AuthDetails implements UserDetails {
/* 인증된 사용자에 대한 세부 정보를 다루는 UserDetails의 구현체(Principal 객체) */
private final Member member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
/* 맴버의 권한 반환 */
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority(member.getMemberRole().name()));
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
/* 사용자의 식별자(일반적으로 유저명이나 이메일) */
return member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Controller에서 사용하기
이제 커스텀 어노테이션을 만들었으므로 실제로 사용해주자. 아래와 같이 아무 API에서나 컨트롤러의 파라미터로 @AuthUser를 명시하면, 내가 구성한 스프링 시큐리티의 로직대로 JWT 토큰 검증 후, 어노테이션에서 명시한 대로 인증이 완료되면 Member 객체를 반환한다. 그래서 Member 객체에서 id 필드 값을 바로 뽑아낸 다음 서비스 계층에 전달할 수 있어서 코드가 매우 간단해진다.
@RestController
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@GetMapping("/chat")
public ResponseEntity<ChatListResponseDto> getOneUserChat(@AuthUser Member member){
ChatListResponseDto oneUserChats = chatService.findOneUserChat(member.getId());
return new ResponseEntity<>(oneUserChats, HttpStatusCode.valueOf(200));
}
}
참고 문헌
Annotations
Many APIs require a fair amount of boilerplate code. For example, in order to write a JAX-RPC web service, you must provide a paired interface and implementation. This boilerplate could be generated automatically by a tool if the program were “decorated
docs.oracle.com
Java @Retention Annotations - GeeksforGeeks
A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.
www.geeksforgeeks.org
Java - @Target Annotations - GeeksforGeeks
A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.
www.geeksforgeeks.org
AuthenticationPrincipalArgumentResolver (spring-security-docs 6.2.3 API)
Allows resolving the Authentication.getPrincipal() using the AuthenticationPrincipal annotation. For example, the following Controller: @Controller public class MyController { @MessageMapping("/im") public void im(@AuthenticationPrincipal CustomUser custom
docs.spring.io