본문 바로가기

개발일지

접근 제어 부분 리팩토링

시작하기에 앞서

프로젝트를 진행하는데 있어 접근 제어에 대한 부분을 Spring Security를 이용해 구현했다.

어떤 부분에 대해 제어가 필요했는지에 관해 먼저 알아보자.

먼저, 기본적인 처리부터 알아보자.

  • 정규 회원의 정보 수정은 웹 관리자 혹은 자기 자신만 가능하다.
  • 정규 회원의 회원 탈퇴는 웹 관리자 혹은 자기 자신만 가능하다.

이 부분에 대한 처리는 어느 웹 서비스에서도 동일하게 적용될 것이다.

우리 서비스에서는 스터디에 대한 서비스를 추가적으로 제공한다.

이 부분에 대해서 추가적인 요구사항이 발생하게 된다. (C - 생성, R - 조회, U - 수정, D - 삭제)

  • 스터디 내부를 들어가는 것은 오직 스터디 회원만 가능하다.
  • 스터디 내부에 추가적인 게시판을 만드는 것은 웹 관리자 혹은 스터디 관리자만 가능하다.
  • 스터디 게시글의 C, R은 오직 스터디 회원만 가능하고 U, D의 경우 스터디 회원이면서 작성자거나 스터디 관리자여야 한다.
  • 스터디 게시글 댓글의 C, R은 오직 스터디 회원만 가능하고 U, D의 경우 스터디 회원이면서 작성자거나 스터디 관리자여야 한다.
  • 스터디 화상회의의 C, R은 오직 스터디 회원만 가능하고 U, D의 경우 스터디 회원이면서 작성자거나 스터디 관리자여야 한다.

전체적인 부분을 가져온 것은 아니지만 위의 사항들만을 읽어봐도 중복되는 부분이 상당히 있다는 것을 볼 수 있다.

이러한 부분에 있어 각 엔티티마다 접근 제어를 해주는 클래스를 따로 만들어야 할까?

일단 따로 만들어보고 어떻게 될지 살펴보자.

회원의 수정과 삭제에 관한 검증을 먼저 구성해보자.

당연히 정규회원이라는 뜻은 인증받은 회원이라는 뜻이기에 조건을 추가했다.

그리고 위에서 언급한 그대로 웹 관리자이거나 정규 회원이면서 리소스(회원)에 대한 권한(소유자)이 있는지를 확인하는 것이다.

@RequiredArgsConstructor
public class MemberAuthChecker {

    private final AuthHelper authHelper;
    private final AuthStrategy authStrategy;

    public boolean check(Long resourceId) {

        // 인증을 받은 유저 중, 웹 관리자이거나 정규 회원이면서 리소스 주인인지 체크
        return authHelper.isAuthenticated() &&
               (authHelper.isAdmin() || authHelper.isRegularMember() && isResourceOwner(resourceId);
    }

    private boolean isResourceOwner(Long id) {
        return id.equals(authHelper.extractMemberId());
    }
}

위 부분에 대한 구현이 상당히 간단하다.

다른 부분을 검증하는 것을 살펴보자.

스터디를 확인해보면 스터디에 접근하는 것과 스터디 내부의 리소스에 대한 단순 C, R은 스터디 회원인지만 검증한다.

스터디 회원이라는 뜻은 당연히 정규회원임을 의미하므로 인증받은 회원이자 정규회원 이상일 것이다.

이 말은 정규 회원이거나 웹 관리자 둘 중 하나라는 뜻이다.

웹 관리자는 당연히 모든 접근에 대한 권한이 존재하므로 위와 비슷한 로직이 탄생할 것이다.

웹 관리자이거나 정규 회원이면서 리소스(스터디)에 대한 권한(스터디 회원)이 있는지를 확인하는 것이다.

이렇게 본다면 위에서 회원에 대한 검증을 진행한 부분과 상당히 비슷하다고 느낄 것이다.

사실 더 높은 개념 즉, 추상화의 개념으로 본다면 대부분에 대한 접근 제어에서 공통된 것이 많음을 확인할 수 있다.

실제 아래 코드를 보고 이를 확인해보자.

@RequiredArgsConstructor
public class StudyAuthChecker {

    private final AuthHelper authHelper;
    private final AuthStrategy authStrategy;
    private final StudyJoinRepository studyJoinRepository;

    public boolean check(Long resourceId) {

        // 인증을 받은 유저 중, 웹 관리자이거나 정규 회원이면서 리소스 주인인지 체크
        return authHelper.isAuthenticated() &&
               (authHelper.isAdmin() || authHelper.isRegularMember() && isResourceOwner(resourceId);
    }

    private boolean isResourceOwner(Long id) {
       StudyJoin studyJoin = studyJoinRepository.findStudyRole(authHelper.extractMemberId(), studyId).orElseThrow(() -> new AccessDeniedException(""));
        return studyJoin.getStudyRole().equals(StudyRole.CREATOR);
    }
}

실제 isResourceOwner을 제외한 코드들이 중복이다.

중복을 제거하고 리팩토링하면 좋겠지만 이런식으로 모든 클래스들을 따로 작성했다고 가정하자.

그런 상황에서 추후 인증을 받지 않은 유저도 다 할 수 있게 해줘보자! (절대 그럴리 없겠지만..) 라고 한다면 전체 클래스를 수정해야하는 상황이 발생한다.

이런 아찔한 상황을 방지하기 위해 중복되는 부분을 제외하고 다른 부분만을 추출해 이를 새롭게 구성해보면 좋을 것 같다.

하지만 전체를 다 하나의 추상체로 추상화하기에는 어려움이 존재할 것 같다.

권한 체크를 하나 이상 진행하는 경우를 확인할 수 있다.

스터디 리소스들(게시판 제외)에서 U, D의 경우이다.

스터디 회원이면서 리소스의 주인이거나 스터디 관리자라는 분기점이 생긴다.

즉, 권한 체크에 대한 부분이 하나가 아니라 여러 가지의 경우라는 것이다.

이에 대한 경우를 코드로 직접 살펴보자.

@RequiredArgsConstructor
public class StudyAuthChecker {

    private final AuthHelper authHelper;
    private final AuthStrategy authStrategy;
    priavte final studyJoinRepository studyJoinRepository
    private final StudyCommentRepository studyCommentRepository;

    public boolean check(Long resourceId) {

        // 인증을 받은 유저 중, 웹 관리자이거나 정규 회원이면서 리소스 주인인지 체크
        return authHelper.isAuthenticated() &&
               (authHelper.isAdmin() || authHelper.isRegularMember() && isResourceOwner(resourceId);
    }

    private boolean isResourceOwner(Long studyId, Long commentId) {
        return (isStudyMember(studyId) && isStudyCommentOwner(commentId)) || isStudyAdminOrCreator(studyId);
    }

    private boolean isStudyMember(Long studyId) {
        return studyJoinRepository.isStudyMember(studyId, authHelper.extractMemberId());
    }

    private boolean isStudyCommentOwner(Long commentId) {
        StudyComment studyComment = studyCommentRepository.findById(commentId).orElseThrow(() -> new AccessDeniedException(""));
        return studyComment.getMember().getId().equals(authHelper.extractMemberId());
    }

    private boolean isStudyAdminOrCreator(Long studyId) {
        StudyJoin studyJoin = studyJoinRepository.findStudyRole(authHelper.extractMemberId(), studyId).orElseThrow(() -> new AccessDeniedException(""));
        return studyJoin.getStudyRole().equals(StudyRole.ADMIN) || studyJoin.getStudyRole().equals(StudyRole.CREATOR);
    }            
}

위와 같이 두 가지 부분에 대한 권한 체크가 필요한 것이다.

스터디 권한(정규 회원, 관리자)가 존재하는지 그리고 스터디 댓글에 대한 권한(주인)이 존재하는지 이다.

이러한 부분에 있어 권한 체크가 한 개 필요한 부분과 두 개 필요한 부분으로 나누어 이를 진행할 수 있도록 하였다.

이제 실제로 어떤식으로 리팩토링을 진행할지 고민해보았다.

각각의 접근 제어들은 유사한 행위들이 존재하고 단순 몇 가지 행위만 다르고 이를 갈아끼우면서 유연하게 진행할 수 있는 방법이 없을까라는 고민을 진행하는데 떠오른 디자인 패턴이 존재했다.

바로 전략 패턴(Strategy Pattern)이였다.

전략 패턴에서 언급하는 전략이 우리의 권한과 비견될 수 있었고 우리의 상황과 맞아 떨어진다고 생각해 이를 진행해보기로 했다.

구현

권한을 검증하는 전략(Strategy)를 실제로 구현해봤다.

public interface AuthStrategy {
    boolean check(Long accessId, Long resourceId);
}

실제 접근 중인 사람과 리소스를 받아 리소스에 대한 권한이 존재하는지를 체크하는 전략을 생성한다.

이에 대한 전략으로 다양한 전략이 도출될 수 있었다.

실제로 회원 자기 자신인지

@Component
public class MemberOwnerStrategy implements AuthStrategy {

    public boolean check(Long accessMemberId, Long ownerId) {
        return accessMemberId.equals(ownerId);
    }
}

게시글의 주인인지

@Component
@RequiredArgsConstructor
public class StudyArticleOwnerStrategy implements AuthStrategy {

    private final StudyArticleRepository studyArticleRepository;

    public boolean check(Long accessMemberId, Long articleId) {
        StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(() -> new AccessDeniedException(""));
        return studyArticle.getCreatorId().equals(accessMemberId);
    }
}

댓글의 주인인지

@Component
@RequiredArgsConstructor
public class StudyCommentOwnerStrategy implements AuthStrategy {

    private final StudyCommentRepository studyCommentRepository;

    public boolean check(Long accessMemberId, Long commentId) {
        StudyComment studyComment = studyCommentRepository.findById(commentId).orElseThrow(() -> new AccessDeniedException(""));
        return studyComment.getCreatorId().equals(accessMemberId);
    }
}

스터디의 관리자인지

@Component
@RequiredArgsConstructor
public class StudyCreatorOrAdminStrategy implements AuthStrategy {

    private final StudyJoinRepository studyJoinRepository;

    public boolean check(Long accessMemberId, Long studyId) {
        StudyJoin studyJoin = studyJoinRepository.findStudyRole(accessMemberId, studyId).orElseThrow(() -> new AccessDeniedException(""));
        return studyJoin.getStudyRole().equals(StudyRole.CREATOR) || studyJoin.getStudyRole().equals(StudyRole.ADMIN);
    }
}

스터디 회원인지

@Component
@RequiredArgsConstructor
public class StudyMemberStrategy implements AuthStrategy {

    private final StudyJoinRepository studyJoinRepository;

    @Override
    public boolean check(Long accessMemberId, Long studyId) {
        return studyJoinRepository.isStudyMember(studyId, accessMemberId);
    }
}

화상회의 주인인지

@Component
@RequiredArgsConstructor
public class VideoRoomOwnerStrategy implements AuthStrategy {

    private final VideoRoomRepository videoRoomRepository;

    @Override
    public boolean check(Long accessMemberId, Long roomId) {
        VideoRoom videoRoom = videoRoomRepository.findByRoomId(roomId);
        return videoRoom.getCreatorId().equals(accessMemberId);
    }
}

이렇게 다양한 전략들이 도출되고 우리는 단순 이러한 전략을 갈아끼워가며 이를 사용하면 된다.

권한 1개에 대해 체크하는 책임을 가진 클래스를 하나 선언했다.

@RequiredArgsConstructor
public class MemberAuthChecker {

    private final AuthHelper authHelper;
    private final AuthStrategy authStrategy;

    public boolean check(Long resourceId) {
        Long accessMemberId = authHelper.extractMemberId();

        // 인증을 받은 유저 중, 웹 관리자이거나 정규 회원이면서 리소스 주인인지 체크
        return authHelper.isAuthenticated() &&
               (authHelper.isAdmin() || authHelper.isRegularMember() && authStrategy.check(accessMemberId, resourceId));
    }
}

그리고 권한 2개에 대해 체크하는 책임을 가진 클래스를 하나 더 선언했다.

사실 isStudyCreatorOrAdminisStudyMember는 전략으로 선언되어 있어 전략을 추가적으로 주입받아 이를 활용할 수 있지만

권한 2개에 대한 체크도 중복이 존재해 중복에 대한 부분을 굳이 전략으로 사용할 필요가 없을 것 같아 다음과 같이 진행했다.

@RequiredArgsConstructor
public class StudyAuthChecker {

    private final StudyJoinRepository studyJoinRepository;
    private final AuthHelper authHelper;
    private final AuthStrategy authStrategy;

    public boolean check(Long studyId, Long resourceId) {
        Long accessMemberId = authHelper.extractMemberId();

        // 인증을 받은 유저 중, 웹 관리자이거나 정규 회원이면서 스터디 관리자이거나 리소스 주인인지 체크
        return authHelper.isAuthenticated() &&
               (authHelper.isAdmin() || (authHelper.isRegularMember() && isStudyCreatorOrAdminOrResourceOwner(accessMemberId, studyId, resourceId)));
    }

    private boolean isStudyCreatorOrAdminOrResourceOwner(Long accessMemberId, Long studyId, Long resourceId) {
        return isStudyCreatorOrAdmin(studyId) || (isStudyMember(studyId) && authStrategy.check(accessMemberId, resourceId));
    }

    private boolean isStudyCreatorOrAdmin(Long studyId) {
        StudyJoin studyJoin = studyJoinRepository.findStudyRole(authHelper.extractMemberId(), studyId).orElseThrow(() -> new AccessDeniedException(""));
        return studyJoin.getStudyRole().equals(StudyRole.ADMIN) || studyJoin.getStudyRole().equals(StudyRole.CREATOR);
    }

    private boolean isStudyMember(Long studyId) {
        return studyJoinRepository.isStudyMember(studyId, authHelper.extractMemberId());
    }
}

이제 실제로 다양한 검증 클래스를 생성해보자.

다음과 같이 단순 전략들을 바꿔 끼워가며 변경만 하면 되며 중복되는 로직에서 수정이 발생시 N개의 수정에서 2번의 수정으로 바뀌었다.

@Configuration
@RequiredArgsConstructor
public class GuardConfig {

    private final StudyJoinRepository studyJoinRepository;
    private final AuthHelper authHelper;

    @Bean
    public MemberAuthChecker MemberOwner(AuthStrategy memberOwnerStrategy) {
        return new MemberAuthChecker(authHelper, memberOwnerStrategy);
    }

    @Bean
    public MemberAuthChecker StudyMember(AuthStrategy studyMemberStrategy) {
        return new MemberAuthChecker(authHelper, studyMemberStrategy);
    }

    @Bean
    public MemberAuthChecker StudyCreator(AuthStrategy studyCreatorStrategy) {
        return new MemberAuthChecker(authHelper, studyCreatorStrategy);
    }

    @Bean
    public MemberAuthChecker StudyCreatorOrAdmin(AuthStrategy studyCreatorOrAdminStrategy) {
        return new MemberAuthChecker(authHelper, studyCreatorOrAdminStrategy);
    }

    @Bean
    public StudyAuthChecker StudyArticleOwner(AuthStrategy studyArticleOwnerStrategy) {
        return new StudyAuthChecker(studyJoinRepository, authHelper, studyArticleOwnerStrategy);
    }

    @Bean
    public StudyAuthChecker StudyCommentOwner(AuthStrategy studyCommentOwnerStrategy) {
        return new StudyAuthChecker(studyJoinRepository, authHelper, studyCommentOwnerStrategy);
    }

    @Bean
    public StudyAuthChecker VideoRoomOwner(AuthStrategy videoRoomOwnerStrategy) {
        return new StudyAuthChecker(studyJoinRepository, authHelper, videoRoomOwnerStrategy);
    }
}

이제 실제로 많은 코드들을 변경했으니 테스트를 돌려 문제가 없는지 확인해야한다. (테스트 코드의 중요성을 계속 체감한다.)

다음과 같이 문제 없이 성공적으로 리팩토링이 된 모습을 확인할 수 있었다.

정리

위에서 진행한 방법이 최선이라고 생각하지는 않습니다.

더 좋은 방법이 있다면 공유해주시면 감사드리겠습니다!

적극적으로 반영하여 수정하도록 하겠습니다.