개발일지

검증의 책임에 대해

gilssang97 2021. 12. 15. 18:18

회원의 권한 검증에 대해

나는 학교 스터디 관련 웹을 만들고 있는 중이다.

해당 웹의 관리자와 일반 회원이 존재한다.

이 웹에서는 다른 웹에서와 마찬가지로 기본적으로 회원은 자신의 프로필 정보를 수정하고 삭제할 수 있어야한다.

관리자는 회원의 닉네임이 부적절하거나 회원의 부적절한 행위에 대해 제제하기 위해 삭제 행위 등을 수행할 수 있어야한다.

또한, 관리자는 스터디에 관한 모든 권한을 가지고 있다.

물론, 관리자가 어떤 누군가가 만든 스터디를 직접적으로 수정할일은 별로 없겠지만 부적절한 스터디로 간주되는 경우 혹은 신고 시스템이 생겨 신고 누적으로 인해 부적절한 스터디로 간주되는 경우 삭제를 진행해야 할 것이다.

그리고 어떤 사람이 스터디를 만들었다. 해당 스터디는 "생성자에 한해" 수정 및 삭제가 가능해야한다.

당연한 말이지만, 단순 스터디에 참여한 사람이나 스터디에 참여하지 않은 일반 사용자는 해당 스터디에 대한 수정 및 삭제 권한이 존재하지 않고 단순 조회만 가능해야 할 것이다.

나는 이러한 권한 검증에 대한 로직 처리를 구현하기로 하였다.

나는 회원인지 아닌지에 대해 인증과 인가처리를 하는데 있어 Security FrameWork를 사용하였다.

Security는 필자의 블로그에서 몇 번 다룬적이 있는데, 인증과 인가처리를 몇몇 설정을 통해 손쉽게 처리해줄 수 있다!

여기서 인증과 인가처리를 기억해두자. (나중에 다시 언급할 예정이다.)

나는 해당 회원에 대해 3가지 권한으로 나누었다.

  • 회원가입을 진행하고 이메일 인증을 하지 않은 회원
  • 회원가입을 진행하고 이메일 인증을 진행한 회원
  • 관리자

우리는 회원가입을 진행하고 인증을 완료한 회원에 대해 회원의 정보를 업데이트하고 삭제를 하는 기능을 제공해야한다.

하지만 우리가 실제 회원의 정보를 수정하고 회원을 삭제하는데 있어 실사용자가 아닌 사용자가 해당 API 요청을 진행하게 두어서는 안된다.

그에 따라, 우리는 2번째 권한 이상을 요구하게 만들어야 함이 분명하다.

처음에는 이 부분을 단순히 Service Layer에서 처리하려 했으나, Controller에서 Service까지 해당 정보를 내리면서까지 처리해야되는 필요성을 느끼지 못해 일단 Controller에서 처리하는 방식으로 진행했다.

다음은 실제로 이를 구현한 예이다.

@ApiOperation(value = "회원 정보 수정", notes = "회원에 대한 정보를 수정한다.")
@PutMapping("/{memberId}")
public SingleResult<MemberUpdateResponseDto> updateMember(@ApiIgnore @AuthenticationPrincipal MemberDetails memberDetails,
                                                          @PathVariable Long memberId,
                                                          @Valid @RequestBody MemberUpdateRequestDto requestDto) {
    validateResourceOwner(memberDetails.getId(), memberId);
    return responseService.getSingleResult(memberService.updateMember(memberId, requestDto));
}

@ApiOperation(value = "회원 삭제", notes = "회원을 삭제한다.")
@DeleteMapping("/{memberId}")
public SingleResult<MemberDeleteResponseDto> deleteMember(@ApiIgnore @AuthenticationPrincipal MemberDetails memberDetails,
                                                          @PathVariable Long memberId) {
    validateResourceOwner(memberDetails.getId(), memberId);
    return responseService.getSingleResult(memberService.deleteMember(memberId));
}

private void validateResourceOwner(Long ActualId, Long requestId) {
    if (!ActualId.equals(requestId))
        throw new ResourceNotOwnerException();
}

위에서 validateResourceOwner에 주목하자.

실제 SecurityContext에 저장되어있는 Authentication에서 정보를 받아와 해당 정보와 실제 요청된 아이디를 비교해 실사용자의 요청인지 확인해야한다.

그리고 다르다면 Exception을 뿌려 오동작을 막아야한다.

실제로 이 부분은 문제 없이 테스트를 통과하고 실제 프론트와 연동시에도 잘 작동하는 코드이다.

하지만 위에서 말한 부분을 다시 살펴보자.

나는 "Security를 통해 인증과 인가에 대한 처리를 진행하려고 했다."라고 위에서 언급했다.

모듈은 한 책임을 가져야한다. (SRP) 에 대해서 들어봤을 것이다.

이는 우리가 추구하는 OOP의 원칙 중 하나이다.

여기서 실제로 바라보면 Controller(혹은 Service) Layer에서 권한이 있는지 검증을 한 후 권한이 존재한다면 (인가) 수정 및 삭제 (로직)을 진행한다.

우리는 실제 인가와 우리가 하려고 하는 로직 두 가지를 수행한다고 바라볼 수 있다.

Security를 통해 인증과 인가 처리를 진행하려고 했으나 이 부분은 Controller(혹은 Service) Layer에서 진행하고 있는 셈이다.

그래서 이 부분에서 생각을 해보았다.

실제 Security를 통해 해당 API에 접근할 때 인증 및 인가 처리를 진행하는게 맞지 않을까라는 생각이다.

이 부분에 대해 자세히 고민해보자.

먼저, 위에서 언급한 책임 측면에서 바라본다면 Security 측에서 인가 처리를 진행한다면 Service Layer에서 인가 처리를 진행하지 않아도 되니 책임이 분산되어 Service Layer는 온전히 자신의 로직만을 수행해 더 좋아질 것이라 생각했다.

그에 따른, 코드의 복잡성 또한 줄어들 것이기에 더 좋다고 생각했다.(인가 로직이 빠짐에 따라 인가 코드 제거)

하지만, 우리가 Security 계층에서 실행될 때 트랜잭션을 열고 해당 회원의 권한 정보를 받아오고 인가 처리를 진행한 후 추후에 다시 Service Layer에서 트랜잭션을 "다시" 열어 이를 처리하게 된다.

비용적인 측면에서 바라볼 때에는 트랜잭션을 한 번 더 연다는 점에서 손해가 존재한다.

개인적인 생각으로 해당 트랜잭션을 한번 더 여는 행위가 시스템에 해악을 끼칠만큼의 영향을 줄까라고 고민해본다면 그렇지 않을 것이라고 생각한다.

또한, 책임을 제대로 분리하고 시스템을 깨끗하게 유지하는 것 자체가 해당 비용에 비해 훨씬 더 중요하다고 생각한다.

실제로 많은 책들에서는 조금의 성능개선보다는 깨끗한 시스템을 구성하는 것이 더 이득이라는 말을 자주하곤 한다.

이러한 관점에서 나는 인가 처리를 Security 계층으로 넘기는 방법으로 바꾸려고 구성해보았다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http

        ...

        .authorizeRequests()

        ...

        .antMatchers(HttpMethod.PUT, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/users/{id}").access("@memberGuard.check(#id)")

        ...

}

위는 Security 설정 일부이다.

이전에 실제 회원에 대한 모든 요청은 hasRole을 이용해 단순 MEMBER의 권한을 가지고 있다면 허용시키고 이후에 로직을 진행하는 방식으로 진행했다면 이번에는 access를 이용해 권한을 미리 체크하는 방식을 구성하려고 하였다.

그래서 다음과 같이 MemberGuard 클래스의 check메소드를 통해 권한을 체크하는 방식을 구성했다.

그전에 MemberGuard의 일부 로직을 담당하는 부분을 AuthHelper 클래스로 구성하여 책임을 분리했다.

@Component
public class AuthHelper {

    public boolean isAuthenticated() {
        return getAuthentication() instanceof UsernamePasswordAuthenticationToken &&
                getAuthentication().isAuthenticated();
    }

    public Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    public Long extractMemberId() {
        return Long.valueOf(getMemberDetails().getId());
    }

    private MemberDetails getMemberDetails() {
        return (MemberDetails) getAuthentication().getPrincipal();
    }

    public String extractMemberRole() {
       return getMemberDetails().getAuthorities()
                   .stream()
                   .map(auth -> auth.getAuthority())
                   .findFirst()
                   .orElseGet(() -> "Anonymous");
    }
}

메소드 이름에서 유추할 수 있듯이, ThreadLocal인 SecurityContext에서 정보를 꺼내와 인증된 유저인지 확인해주는 메소드와 해당 UserDetails (실제로는 UserDetails를 커스터마이징한 MemberDetails)를 가져와 ID(PK)와 권한을 가져오는 메소드를 구성했다.

이를 이용해 다음과 같이 MemberGuard를 구성했다.

@Component
@RequiredArgsConstructor
public class MemberGuard {
    private final AuthHelper authHelper;

    public boolean check(Long id) {
        return authHelper.isAuthenticated() && hasAuthority(id);
    }

    private boolean hasAuthority(Long id) {
        return (isAuthMember() && isResourceOwner(id)) || isAdmin();
    }

    private boolean isAuthMember() {
        return authHelper.extractMemberRole().equals("ROLE_MEMBER");
    }

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

    private boolean isAdmin() {
        return authHelper.extractMemberRole().equals("ROLE_ADMIN");
    }
}

check에서 볼 수 있듯이, 인증된 유저인지 권한을 가지고 있는 유저인지를 확인했다.

확인하는 과정은 간단하게 회원 "본인"인지 혹은 관리자인지를 확인하여 권한이 있는지를 판단한다.

이러한 과정을 통해 이전에 Controller에서 진행한 코드가 다음과 같이 깔끔하게 변한다.

@ApiOperation(value = "회원 정보 수정", notes = "회원에 대한 정보를 수정한다.")
@PutMapping("/{memberId}")
public SingleResult<MemberUpdateResponseDto> updateMember(@PathVariable Long memberId,
                                                          @Valid @RequestBody MemberUpdateRequestDto requestDto) {
    return responseService.getSingleResult(memberService.updateMember(memberId, requestDto));
}

@ApiOperation(value = "회원 삭제", notes = "회원을 삭제한다.")
@DeleteMapping("/{memberId}")
public SingleResult<MemberDeleteResponseDto> deleteMember(@PathVariable Long memberId) {
    return responseService.getSingleResult(memberService.deleteMember(memberId));
}

단순 파라미터 하나, 메소드 한 줄이 줄었다고 할 수 있으나 이 부분은 충분히 좋은 결과라고 생각한다.

이를 넘어 스터디에 대한 권한에 대해 살펴보자.

나는 대부분의 조회 (GET)에 대해서는 권한에 대한 체크없이 열어두었지만 스터디를 생성하고 참여하고 수정하는 등 다양한 작업에 대해서는 위에서 언급한 2번째 권한 이상을 요구하게 만들었다.

이렇게 나는 인증처리를 진행하고 인증처리를 진행한 회원은 스터디를 만들 수 있다.

해당 회원은 스터디를 참여함과 동시에 "스터디에 대한 생성자" 권한을 얻게된다.

추후 다른 회원들이 스터디에 참여한다면 "스터디에 참여한 일반 회원"이라는 권한을 얻게될 것이다. (물론, 스터디 관리를 생성자 혼자서 처리하기 힘들어 관리자라는 등급을 추가했다. 또한, 위에서 언급한 이메일을 인증한 유저 등과 같은 권한과는 "별개"이며 스터디에 대한 "별개"의 권한이다.)

생성한 회원은 스터디를 만든 후 해당 스터디를 수정하고 삭제하고 싶을 수 있다.

우리는 수정 및 삭제하고자 하는 스터디가 다른 누군가에 의해 수정 및 삭제되는 것을 방지하고자 회원이 실제 이 스터디의 "생성자"인지를 확인해야 할 것이다.

이 부분에 대해서도 처음 구현할 당시에는 단순 Service Layer에서 처리하는 방식으로 생각했다.

다음은 실제로 이를 구현한 예이다.

@Override
@Transactional
public StudyUpdateResponseDto updateStudy(Long memberId, Long studyId, StudyUpdateRequestDto requestDto) {
    validateAuthority(memberId, studyId);
    Study study = studyRepository.findById(studyId).orElseThrow(StudyNotFoundException::new);
    return StudyUpdateResponseDto.create(study.updateStudyInfo(requestDto));
}

@Override
@Transactional
public StudyDeleteResponseDto deleteStudy(Long memberId, Long studyId) {
    validateAuthority(memberId, studyId);
    Study study = studyRepository.findById(studyId).orElseThrow(StudyNotFoundException::new);
    studyRepository.delete(study);
    return StudyDeleteResponseDto.create(study);
}

private void validateAuthority(Long memberId, Long studyId) {
    StudyJoin studyJoin = studyJoinRepository.findByMemberIdAndStudyId(memberId, studyId).orElseThrow(NotBelongStudyMemberException::new);
    if (!studyJoin.getStudyRole().equals(StudyRole.CREATOR))
        throw new StudyHasNoProperRoleException();
}

위의 코드는 스터디를 수정하고 삭제하는 로직으로 Service Layer에서 일부 발췌했다.

위에서 validateAuthority를 주목해보자.

스터디에 참여한 회원인지를 확인하고 해당 회원의 권한을 확인해 CREATOR(생성자)인지를 확인한 후 수정 및 삭제를 진행하는 모습을 볼 수 있다.

이제 실제 회원과 마찬가지로 Security 쪽으로 책임을 넘겨보자.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http

        ...

        .authorizeRequests()

        ...

        .antMatchers(HttpMethod.PUT, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.PUT, "/study/{id}").access("@studyGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/study/{id}").access("@studyGuard.check(#id)")

        ...

}

다음은 Study에 대한 수정과 삭제를 추가시킨 모습이다.

그리고 Study를 담당하는 StudyGuard 클래스를 다음과 같이 구현했다.

@Component
@RequiredArgsConstructor
public class StudyGuard {
    private final AuthHelper authHelper;
    private final StudyJoinRepository studyJoinRepository;

    public boolean check(Long studyId) {
        return authHelper.isAuthenticated() && hasAuthority(studyId);
    }

    private boolean hasAuthority(Long studyId) {
        return (isAuthMember() && isStudyCreator(studyId)) || isAdmin();
    }

    private boolean isAuthMember() {
        return authHelper.extractMemberRole().equals("ROLE_MEMBER");
    }

    private boolean isStudyCreator(Long studyId) {
        StudyJoin studyJoin = studyJoinRepository.findStudyRole(authHelper.extractMemberId(), studyId);
        return studyJoin.getStudyRole().equals("STUDY_CREATOR");
    }

    private boolean isAdmin() {
        return authHelper.extractMemberRole().equals("ROLE_ADMIN");
    }
}

이 부분은 회원인지 그리고 실제 스터디를 만든 "생성자"인지를 확인하여 이를 인가시켜줄지말지 결정해주는 것이다.

이제 실제 Service는 다음과 같이 간단 명료하게 바뀌었다.

@Override
@Transactional
public StudyUpdateResponseDto updateStudy(Long memberId, Long studyId, StudyUpdateRequestDto requestDto) {
    Study study = studyRepository.findById(studyId).orElseThrow(StudyNotFoundException::new);
    return StudyUpdateResponseDto.create(study.updateStudyInfo(requestDto));
}

@Override
@Transactional
public StudyDeleteResponseDto deleteStudy(Long memberId, Long studyId) {
    Study study = studyRepository.findById(studyId).orElseThrow(StudyNotFoundException::new);
    studyRepository.delete(study);
    return StudyDeleteResponseDto.create(study);
}

아직 검증에 대해 수정할 부분이 남아있다.

스터디 내부에 주어진 게시판과 게시글에 대해서 수정과 삭제에 대해 검증해야한다.

게시판은 스터디 관리자 혹은 생성자가 가능하고 웹의 총 관리자가 가능할 것이다.

게시글은 해당 스터디에 소속된 사람이자 작성자 그리고 스터디의 관리자 혹은 생성자 그리고 웹의 총 관리자가 가능할 것이다.

이 부분에 대해서 구현해보았다.

먼저, 게시판이다.

Security 관련은 이제 위에서 충분히 설명했으니 다음과 같이 코드만 남겨두었다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http

        ...

        .authorizeRequests()

        ...

        .antMatchers(HttpMethod.PUT, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.PUT, "/study/{id}").access("@studyGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/study/{id}").access("@studyGuard.check(#id)")
        .antMatchers(HttpMethod.PUT, "/study/{studyId}/board/*").access("@studyBoardGuard.check(#studyId)")
        .antMatchers(HttpMethod.DELETE, "/study/{studyId}/board/*").access("@studyBoardGuard.check(#studyId)")

        ...

}
@Component
@RequiredArgsConstructor
public class StudyBoardGuard {
    private final AuthHelper authHelper;
    private final StudyJoinRepository studyJoinRepository;

    public boolean check(Long studyId) {
        return authHelper.isAuthenticated() && hasAuthority(studyId);
    }

    private boolean hasAuthority(Long studyId) {
        return (isAuthMember() && isStudyAdminOrCreator(studyId)) || isAdmin();
    }

    private boolean isAuthMember() {
        return authHelper.extractMemberRole().equals("ROLE_MEMBER");
    }

    private boolean isStudyAdminOrCreator(Long studyId) {
        StudyJoin studyJoin = studyJoinRepository.findStudyRole(authHelper.extractMemberId(), studyId);
        return studyJoin.getStudyRole().equals("STUDY_ADMIN") ||
                studyJoin.getStudyRole().equals("STUDY_CREATOR");
    }

    private boolean isAdmin() {
        return authHelper.extractMemberRole().equals("ROLE_ADMIN");
    }
}

코드가 다음과 같았는데

@Override
@Transactional
public StudyBoardUpdateResponseDto updateBoard(Long memberId, Long studyId, Long studyBoardId, StudyBoardUpdateRequestDto requestDto) {
    validateAuthority(memberId, studyId);
    StudyBoard studyBoard = studyBoardRepository.findById(studyBoardId).orElseThrow(StudyBoardNotFoundException::new);
    studyBoard.changeTitle(requestDto.getTitle());
    return StudyBoardUpdateResponseDto.create(studyBoard);
}

@Override
@Transactional
public StudyBoardDeleteResponseDto deleteBoard(Long memberId, Long studyId, Long studyBoardId) {
    validateAuthority(memberId, studyId);
    StudyBoard studyBoard = studyBoardRepository.findById(studyBoardId).orElseThrow(StudyBoardNotFoundException::new);
    studyBoardRepository.delete(studyBoard);
    return StudyBoardDeleteResponseDto.create(studyBoard);
}

private void validateAuthority(Long memberId, Long studyId) {
    StudyJoin studyJoin = studyJoinRepository.findStudyRole(memberId, studyId);
    if (!(studyJoin.getStudyRole().equals(StudyRole.CREATOR) || studyJoin.getStudyRole().equals(StudyRole.ADMIN)))
        throw new StudyHasNoProperRoleException();
}

이와 같이 간단하게 변경되었다.

@Override
@Transactional
public StudyBoardUpdateResponseDto updateBoard(Long memberId, Long studyId, Long studyBoardId, StudyBoardUpdateRequestDto requestDto) {
    StudyBoard studyBoard = studyBoardRepository.findById(studyBoardId).orElseThrow(StudyBoardNotFoundException::new);
    studyBoard.changeTitle(requestDto.getTitle());
    return StudyBoardUpdateResponseDto.create(studyBoard);
}

@Override
@Transactional
public StudyBoardDeleteResponseDto deleteBoard(Long memberId, Long studyId, Long studyBoardId) {
    StudyBoard studyBoard = studyBoardRepository.findById(studyBoardId).orElseThrow(StudyBoardNotFoundException::new);
    studyBoardRepository.delete(studyBoard);
    return StudyBoardDeleteResponseDto.create(studyBoard);
}

다음은 게시글이다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http

        ...

        .authorizeRequests()

        ...

        .antMatchers(HttpMethod.PUT, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/users/{id}").access("@memberGuard.check(#id)")
        .antMatchers(HttpMethod.PUT, "/study/{id}").access("@studyGuard.check(#id)")
        .antMatchers(HttpMethod.DELETE, "/study/{id}").access("@studyGuard.check(#id)")
        .antMatchers(HttpMethod.PUT, "/study/{studyId}/board/*").access("@studyBoardGuard.check(#studyId)")
        .antMatchers(HttpMethod.DELETE, "/study/{studyId}/board/*").access("@studyBoardGuard.check(#studyId)")
        .antMatchers(HttpMethod.GET, "/study/{studyId}/board/*/article/*").access("@studyArticleGuard.checkJoin(#studyId)")
        .antMatchers(HttpMethod.POST, "/study/{studyId}/board/*/article/*").access("@studyArticleGuard.checkJoin(#studyId)")
        .antMatchers(HttpMethod.PUT, "/study/{studyId}/board/*/article/{articleId}").access("@studyArticleGuard.checkJoinAndAuth(#studyId, #articleId)")
        .antMatchers(HttpMethod.DELETE, "/study/{studyId}/board/*/article/{articleId}").access("@studyArticleGuard.checkJoinAndAuth(#studyId, #articleId)")

        ...

}

다음과 같은 복잡한 검증로직이 빠져

    @Override
    @Transactional
    public StudyArticleCreateResponseDto createArticle(Long memberId, Long studyId, Long boardId, StudyArticleCreateRequestDto requestDto) {
        StudyArticle studyBoard = studyArticleRepository.save(requestDto.toEntity(boardId));
        validateJoinedMember(memberId, studyId);
        return StudyArticleCreateResponseDto.create(studyBoard);
    }

    @Override
    public List<StudyArticleFindResponseDto> findAllArticles(Long memberId, Long studyId, Long boardId) {
        List<StudyArticle> studyBoards = studyArticleRepository.findAllArticles(boardId);
        validateJoinedMember(memberId, studyId);
        return studyBoards.stream()
                .map(studyBoard -> StudyArticleFindResponseDto.create(studyBoard))
                .collect(Collectors.toList());
    }

    @Override
    public StudyArticleFindResponseDto findArticle(Long memberId, Long studyId, Long articleId) {
        StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
        validateJoinedMember(memberId, studyId);
        return StudyArticleFindResponseDto.create(studyArticle);
    }

    @Override
    @Transactional
    public StudyArticleUpdateResponseDto updateArticle(Long memberId, Long studyId, Long articleId, StudyArticleUpdateRequestDto requestDto) {
        StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
        validateJoinedMember(memberId, studyId);
        validateArticleWriter(memberId, studyArticle.getMember().getId());
        studyArticle.updateArticleInfo(requestDto);
        return StudyArticleUpdateResponseDto.create(studyArticle);
    }

    @Override
    @Transactional
    public StudyArticleDeleteResponseDto deleteArticle(Long memberId, Long studyId, Long articleId) {
        StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
        validateJoinedMember(memberId, studyId);
        validateArticleWriter(memberId, studyArticle.getMember().getId());
        studyArticleRepository.delete(studyArticle);
        return StudyArticleDeleteResponseDto.create(studyArticle);
    }

    private void validateJoinedMember(Long memberId, Long studyId) {
        if (!studyJoinRepository.exist(studyId, memberId))
            throw new NotBelongStudyMemberException();
    }

    private void validateArticleWriter(Long requestMemberId, Long writeMemberId) {
        if (requestMemberId != writeMemberId)
            throw new StudyArticleNotWriterException();
    }

이와 같은 깔끔한 로직으로만 구성된 Service로 변했다.

@Override
@Transactional
public StudyArticleCreateResponseDto createArticle(Long memberId, Long studyId, Long boardId, StudyArticleCreateRequestDto requestDto) {
    StudyArticle studyBoard = studyArticleRepository.save(requestDto.toEntity(boardId));
    return StudyArticleCreateResponseDto.create(studyBoard);
}

@Override
public List<StudyArticleFindResponseDto> findAllArticles(Long memberId, Long studyId, Long boardId) {
    List<StudyArticle> studyBoards = studyArticleRepository.findAllArticles(boardId);
    return studyBoards.stream()
            .map(studyBoard -> StudyArticleFindResponseDto.create(studyBoard))
            .collect(Collectors.toList());
}

@Override
public StudyArticleFindResponseDto findArticle(Long memberId, Long studyId, Long articleId) {
    StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
    return StudyArticleFindResponseDto.create(studyArticle);
}

@Override
@Transactional
public StudyArticleUpdateResponseDto updateArticle(Long memberId, Long studyId, Long articleId, StudyArticleUpdateRequestDto requestDto) {
    StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
    studyArticle.updateArticleInfo(requestDto);
    return StudyArticleUpdateResponseDto.create(studyArticle);
}

@Override
@Transactional
public StudyArticleDeleteResponseDto deleteArticle(Long memberId, Long studyId, Long articleId) {
    StudyArticle studyArticle = studyArticleRepository.findById(articleId).orElseThrow(StudyArticleNotFoundException::new);
    studyArticleRepository.delete(studyArticle);
    return StudyArticleDeleteResponseDto.create(studyArticle);
}

다음과 같이 검증로직을 Service에서 분리하니 검증로직에 대한 부분이 사라져 가독성이 올랐다.

다음과 같이 코드를 수정하고 조금의 리팩토링을 거친 뒤 코드가 실행되지 않을 부담과 걱정은 존재하지 않는다.

바로 테스트 코드들을 만들어두었기 때문이다.

아래는 바꾼 후 만들어둔 테스트 코드들을 실행한 결과이다.

모두 깔끔하게 통과된다.

지금까지 검증 로직을 Security로 옮기고 책임과 역할을 좀 더 명확하게 나누려고 했다.

그 후, 테스트 코드를 통해 코드 안정성을 검증했다.

여기서, 테스트의 중요성을 다시 한 번 확인할 수 있다.