안녕하세요. 이번 포스팅에서는 회원의 권한 관리라는 주제로 글을 작성하고자 합니다. Spring에서 제공하는 Interceptor와 Annotation을 활용하여 구현하였습니다. 부족한 부분은 언제나 댓글로 알려주시면 바로바로 반영하겠습니다😊

권한 관리 - Authorizaion

어플리케이션은 일반적으로 다양한 사용자들이 존재합니다. 예를 들어 일반 사용자와 관리자가 있다고 가정해 볼게요. 이 경우 관리자만이 할 수 있는 기능을 일반 사용자가 접근하게 된다면 어떻게 될까요? 굉장히 위험하겠죠. 이렇게 서로 다른 권한을 가진 사용자들을 분리하고, 권한에 적절한 API 호출만 가능하도록 하는 것을 권한 관리(Authorizaion)이라고 합니다.

우아한 테크 마켓은 처음 가입한 GUEST, 필수 정보를 입력한 USER, 그리고 관리자 이렇게 총 세개의 권한이 존재합니다. 위의 예시처럼 각기 다른 권한을 가진 사람이 접근할 수 있는 API는 제한적이여야 하기 때문에 권한과 관련된 기능을 구현해 보았습니다.

고민 거리

일반적으로 권한이나 인증과 관련된 부분은 비즈니스 로직이 아닌 Filter, Interceptor 등에서 처리됩니다. 저의 경우 회원이 존재하는지 찾아와야 하는 로직이 포함되기 때문에 Interceptor를 선택하였습니다.(스프링 환경이 동작한 이후에 빈을 사용할 수 있기 때문에)

구현 과정에 있어서 고민은 아래의 두 가지 경우 중 어떤 것이 적합할까? 였습니다. 서로 다른 장단점이 존재하지만 저는 어노테이션을 기반으로 전자를 선택하여 진행하였습니다.! 이유는 아래에 같이 명시해두었습니다. 혹시 더 좋은 아이디어나 생각이 있으시다면 알려주세요!!👨‍💻

  • Controller 에 명시적으로 접근 권한에 대한 정보를 보여주는 방법 ✔️ 제가 선택한 방법입니다.
    • 이유 1. 권한과 관련된 부분은 비즈니스에서 매우 중요합니다. 명시적으로 드러나게 코딩하지 않는다면 본인도 실수할 수 있고, 저의 프로젝트에 참여한 다른 팀원도 실수하기 좋다고 생각해서 꼭 명시적으로 표기하고 싶었습니다.
    • 이유 2. Controller는 비즈니스 로직이라기 보단 사용자와 서버를 이어주는 Endpoint 입니다. 도메인이나 서비스에서 권한과 관련된 중복된 코드가 많아지는 것은 바람직하지 않다고 생각합니다. 하지만 컨트롤러는 그러한 역할을 수행하는 곳이기 때문에 명시적으로 작성하고자 하였습니다.
    • 이유 3. 모든 EndPoint(Method) 마다 Annotation이 추가되는 것은 적절하지 않다고 생각했습니다. 따라서 어노테이션을 클래스와 메서드에 둘다 사용 가능하게 하고, 메소드에 있는 경우 메소드 어노테이션을 우선시한다면 그렇게 많은 중복이 발생할 것 같지 않았습니다.
  • Controller 에는 표현되지 않는 형태로 권한을 관리하는 방법 𝗫 선택 하지 않음.
    • 장점 . 동일한 기능을 제공하지만 설정을 통해 중복되는 코드를 많이 줄일 수 있습니다.
    • 단점 . API 개발자가 API를 설계할 때 권한과 관련된 부분을 놓칠 확률이 높습니다.

진행 순서

구현 내용을 보여드리기에 앞서 전체적인 로직은 다음과 같이 수행됩니다. 코드를 보기 전에 확인하시는 게 이해하기 편할 것 같아서요

  1. 요청을 처리하는 Handler 혹은 클래스에 @Permission(target = XXX) 이라는 어노테이션을 사용한다.
  2. 요청이 들어왔을 때 해당 어노테이션을 읽는다.
  3. 클래스 어노테이션과 메소드 어노테이션이 함께 존재하는 경우 메소드 어노테이션을 기준으로 한다.
  4. 접속한 사용자를 찾아 온다.
  5. 접속한 사용자의 권한이 해당 어노테이션에 있는 어노테이션 이상인지(관리자라면 GUEST API도 사용할 수 있어야겠죠?) 확인한다.
  6. 결과를 반환한다.

구현

주석으로 간단하게 설명 달았습니다. 혹시 이해 안되시는 부분이나 별로인 부분이 있다면 얘기해주세요. 👉

Annotation

@Target(value = {ElementType.METHOD, ElementType.TYPE}) // 클래스와 메소드 모두 사용
@Retention(RetentionPolicy.RUNTIME)
public @interface Permission {
    String target() default "NONE";
}

Controller와 Handler

@RequiredArgsConstructor
@Permission(target = "USER") // 모든 Handler는 기본적으로 USER가 사용할 수 있는 API
@RequestMapping("/api/members")
@RestController
public class MemberController {
    private final MemberService memberService;

    @Permission(target = "NONE") // 해당 Handler는 NONE(아무나)도 사용 가능
    @PostMapping
    public ResponseEntity<Void> create(@Valid @RequestBody MemberCreateRequest request) {
        MemberResponse response = memberService.createMember(request);

        return ResponseEntity.created(URI.create("/api/members/" + response.getId())).build();
    }
}

BearerInterceptor

@RequiredArgsConstructor
@Component
public class BearerInterceptor implements HandlerInterceptor {
    private final AuthenticationExtractor extractor;
    private final JwtTokenProvider provider;
    private final MemberService memberService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod)) { // 호출되는 메소드가 헨들러가 아니라면 검증할 필요가 없겠죠?
            return true;
        }

        Role role = getProperRole((HandlerMethod)handler); // Handler에 달린 어노테이션을 읽어 역할을 가져옵니다.
        if (role.isNone()) { // 역할이 NONE(아무나)라면 바로 통과합니다.
            return true;
        }
        String bearer = extractor.extract(request); // 아니라면 Header로 넘어온 토큰을 해체합니다.
        String kakaoId = provider.getSubject(bearer);
        Member findMember = findMember(kakaoId); // 접속한 회원을 조회합니다.
        request.setAttribute("member", findMember);
        if (role.isGuest() || findMember.hasPermission(role)) {
            return true; // 권한이 확인된 사용자는 통과합니다. 
        }                // 참고로 로그인을 했다면 GUEST 기능은 모두 이용 가능해서 GUEST 도 추가되어 있습니다.
        throw new AuthorizationException();
    }

    private Member findMember(String kakaoId) {
        try {
            return memberService.findByKakaoId(Long.parseLong(kakaoId));
        } catch (NumberFormatException e) {
            throw new NotFoundMemberException();
        }
    }

    private Role getProperRole(HandlerMethod handler) {
        Permission clazzAnnotation = handler.getBean().getClass().getAnnotation(Permission.class);
        Permission methodAnnotation = handler.getMethod().getAnnotation(Permission.class);
        Permission priority = AnnotationUtils.getPriority(clazzAnnotation, methodAnnotation);
        return Role.valueOf(priority.target()); // 어노테이션을 분석해 적절한 권한을 반환합니다.
    }
}

AnnotationUtils - 클래스와 메소드 어노테이션 중 우선순위를 가져옵니다.

public class AnnotationUtils {
    public static Permission getPriority(Permission clazzAnnotation, Permission methodAnnotation) {
        if (Objects.isNull(clazzAnnotation) && Objects.isNull(methodAnnotation)) {
            throw new AssertionError("인가와 관련된 어노테이션을 추가해주세요.");
        }

        if (Objects.nonNull(methodAnnotation)) {
            return methodAnnotation;
        }

        return clazzAnnotation;
    }
}

결론 및 부가 생각

실무에서 어떤 식으로 권한을 관리하는지 잘 몰라서 개인적인 방법으로 적용해 보았습니다. 권한과 관련된 로직은 비즈니스 로직과 분리되어야 된다고 생각하여 인터셉터에서 처리했습니다. 더 나은 방법이나, 다른 방법이 있다면 알려주세요!!

추가적인 생각들

  • 관리자와 사용자 등을 관리하는 서버가 달라진다면 해당 로직은 없어져도 될 것 같습니다. 😀
  • 인증과 인가가 한 곳에서 이루어지는 것이 맞을까 라는 고민이 조금 들었습니다. 현재는 단순한 로직이지만 로직이 복잡해진다면 인터셉터를 분리하는 형태로 하는 것이 맞다고 생각합니다.
  • 저의 서비스에서 대부분의 API는 USER 권한을 가진 사람을 위한 것입니다. 그래서 예외적인 경우(GUEST, NONE, ADMIN)에 대해서만 어노테이션을 사용해도 구현이 가능합니다. 하지만 저는 권한과 관련된 부분은 빠트리기 좋다고 생각하여 명시적으로 작성하는 것을 유도하는 것이 큰 장애를 막을 수 있지 않을까? 라는 생각으로 최대한 의도가 드러나는 코드를 작성하기 위해 노력하였습니다.

3개의 댓글

comment-user-thumbnail
2020년 9월 27일

악플이라도 하나! 밥먹고 읽으러 올게요~

1개의 답글
comment-user-thumbnail
2021년 4월 1일

잘보고갑니당! 우테코에서 배우시는건가욜

답글 달기