반응형

이번 시간에는 권한에 따른 API 접근 제어 방식을 수정해보도록 하겠습니다.

 

 

먼저 기존의 방식을 살펴보겠습니다.

package kukekyakya.kukemarket.config.security;

import ...

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...
                .and()
                    .authorizeRequests()
                        .antMatchers(HttpMethod.GET, "/image/**").permitAll()
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
                        .antMatchers(HttpMethod.POST, "/api/categories/**").hasRole("ADMIN")
                        .antMatchers(HttpMethod.DELETE, "/api/categories/**").hasRole("ADMIN")
                        .antMatchers(HttpMethod.POST, "/api/posts").authenticated()
                        .antMatchers(HttpMethod.PUT, "/api/posts/{id}").access("@postGuard.check(#id)")
                        .antMatchers(HttpMethod.DELETE, "/api/posts/{id}").access("@postGuard.check(#id)")
                        .antMatchers(HttpMethod.POST, "/api/comments").authenticated()
                        .antMatchers(HttpMethod.DELETE, "/api/comments/{id}").access("@commentGuard.check(#id)")
                        .antMatchers(HttpMethod.GET, "/api/messages/sender", "/api/messages/receiver").authenticated()
                        .antMatchers(HttpMethod.GET, "/api/messages/{id}").access("@messageGuard.check(#id)")
                        .antMatchers(HttpMethod.POST, "/api/messages").authenticated()
                        .antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").access("@messageSenderGuard.check(#id)")
                        .antMatchers(HttpMethod.DELETE,"/api/messages/receiver/{id}").access("@messageReceiverGuard.check(#id)")
                        ...;
    }
    ...
}

API마다 별도의 Guard를 작성하여, 세밀하게 접근을 제어하고 있습니다.

 

 

그러면 Guard 구현체 중 하나인, PostGuard를 살펴보겠습니다.

package kukekyakya.kukemarket.config.security.guard;

import ...

@Component
@RequiredArgsConstructor
public class PostGuard extends Guard {
    private final PostRepository postRepository;
    private List<RoleType> roleTypes = List.of(RoleType.ROLE_ADMIN);

    @Override
    protected List<RoleType> getRoleTypes() {
        return roleTypes;
    }

    @Override
    protected boolean isResourceOwner(Long id) {
        Post post = postRepository.findById(id).orElseThrow(() -> { throw new AccessDeniedException(""); });
        Long memberId = AuthHelper.extractMemberId();
        return post.getMember().getId().equals(memberId);
    }
}

데이터베이스에 접근하여 게시글의 작성자와 요청자가 일치하는지 검사하고 있습니다.

 

이러한 코드를 작성하던 이유는, 다음 게시글에 정리되어 있습니다. 

2021.12.02 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (8) - 로그인 - 5 - 인증 및 인가 - 1

 

결국 위 글에 서술된 내용을 간단히 정리해보면,

"우리의 서비스는 인증 서비스가 아니다. 접근 제어 정책 코드는 비즈니스 로직으로 보기 힘들기 때문에, 서비스 로직에 해당 코드를 넣는 것은 애매하다"는 것이었습니다.

서비스 로직에 접근 제어 코드를 삽입한다면, 서비스 코드는 이러한 코드로 범벅이 될 것이고, 각 메소드마다 유사한 제어 코드가 중복되고 말 것입니다.

이를 방지하기 위해 Security에서 Guard를 이용한 접근 제어를 하고 있던 것입니다.

 

하지만 이러한 방식에는 문제점이 있습니다.

요청마다 두 번의 트랜잭션(Security 계층에서 접근 권한 검사 + 서비스 코드)이 열리고 있는 것입니다.

서비스 로직에 접근 제어 정책 코드를 삽입하는 것은 여전히 애매하다고 보지만, 트랜잭션이 두 번 열리는 상황을 감내할 이유는 없습니다.

트랜잭션이 두 번 열리는 탓에 Guard에서 이미 조회했던 데이터를, 서비스에서 한번 더 조회하는 문제도 있습니다.

 

이 상황을 해결해봅시다.

우리는 컨트롤러와 서비스 계층 사이에, 접근 제어를 위한 계층을 새롭게 구축할 것입니다.

접근 제어 계층에서 트랜잭션을 미리 열어둔다면, 1번의 트랜잭션으로도 충분히 접근 제어와 비즈니스 로직을 수행할 수 있고,

서비스 코드에서 접근 제어 코드를 작성할 필요는 없어지게 됩니다.

또, 접근 제어 계층에서 조회했던 데이터는 이미 컨텍스트에 캐시되어있기 때문에, 동일한 쿼리를 중복 수행하는 상황도 없어지게 됩니다.

 

물론, 이러한 계층을 직접 구축할 것은 아니고, Spring Security에서 제공해주는 기능을 이용하도록 하겠습니다.

@PreAuthorize를 메소드 레벨에 지정하여 AOP를 통해 접근 제어 계층을 구축할 것입니다.

 

SecurityConfig에 @EnableGlobalMethodSecurity(prePostEnabled = true) 어노테이션을 지정해주겠습니다.

메소드 레벨에 Security 설정을 할 수 있도록 활성화하고, prePostEnabled 설정을 통해 @PreAuthorize 또는 @PostAuthorize를 이용할 수 있습니다.

메소드 수행 전후에 권한 검사를 할 수 있게 되는 것입니다.

 

***

작성 당시 생각을 잘못 했어서 보충 내용만 남깁니다. AOP를 이용하여 트랜잭션을 한 번만 열 수는 있지만, @PreAuthorize를 이용하는 아래 방식에서는 트랜잭션이 한 번만 열리지 않을 수 있으며(Guard에서 트랜잭션이 끝나버림), 본래 서비스 로직에서 호출하는 쿼리와 Guard에서 호출하는 쿼리가 다르다면 1차 캐시를 이용하지 못할 수도 있습니다. 이를 해결하기 위해서는 Aspect에서 트랜잭션을 미리 열어두거나 @Order를 활용하는 등의 방식으로 AOP 적용 순서를 제어하고, 캐시된 엔티티를 가져올 수 있도록 쿼리를 작성해주면 됩니다.

***

 

접근 제어 정책이 필요한 서비스 로직에 @PreAuthorize를 선언해주고, Guard를 이용하여 접근을 제어하던 SecurityConfig에서는 인증된 사용자인지만 판별해주도록 하겠습니다.

configure 설정도 알맞게 바꿔줍시다.

 

수정된 SecurityConfig는 다음과 같습니다.

@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...
                    .authorizeRequests()
                        .antMatchers(HttpMethod.GET, "/image/**").permitAll()
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up", "/api/refresh-token").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").authenticated()
                        .antMatchers(HttpMethod.POST, "/api/categories/**").hasRole("ADMIN")
                        .antMatchers(HttpMethod.DELETE, "/api/categories/**").hasRole("ADMIN")
                        .antMatchers(HttpMethod.POST, "/api/posts").authenticated()
                        .antMatchers(HttpMethod.PUT, "/api/posts/{id}").authenticated()
                        .antMatchers(HttpMethod.DELETE, "/api/posts/{id}").authenticated()
                        .antMatchers(HttpMethod.POST, "/api/comments").authenticated()
                        .antMatchers(HttpMethod.DELETE, "/api/comments/{id}").authenticated()
                        .antMatchers(HttpMethod.GET, "/api/messages/sender", "/api/messages/receiver").authenticated()
                        .antMatchers(HttpMethod.GET, "/api/messages/{id}").authenticated()
                        .antMatchers(HttpMethod.POST, "/api/messages").authenticated()
                        .antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").authenticated()
                        .antMatchers(HttpMethod.DELETE,"/api/messages/receiver/{id}").authenticated()
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                        .anyRequest().hasAnyRole("ADMIN")
                ...
}

인증된 사용자인지만 검사해주고 있습니다.

 

 

이제 접근 제어가 필요한 각각의 서비스 로직에 @PreAuthorize를 지정해주겠습니다.

// CommentService.java
@Transactional
@PreAuthorize("@commentGuard.check(#id)")
public void delete(Long id) {
    Comment comment = commentRepository.findWithParentById(id).orElseThrow(CommentNotFoundException::new);
    comment.findDeletableComment().ifPresentOrElse(commentRepository::delete, comment::delete);
}
// MemberService.java
@Transactional
@PreAuthorize("@memberGuard.check(#id)")
public void delete(Long id) {
    Member member = memberRepository.findById(id).orElseThrow(MemberNotFoundException::new);
    memberRepository.delete(member);
}
// MessageService.java
@PreAuthorize("@messageGuard.check(#id)")
public MessageDto read(Long id) {
    return MessageDto.toDto(
            messageRepository.findWithSenderAndReceiverById(id).orElseThrow(MessageNotFoundException::new)
    );
}

@Transactional
@PreAuthorize("@messageSenderGuard.check(#id)")
public void deleteBySender(Long id) {
    delete(id, Message::deleteBySender);
}

@Transactional
@PreAuthorize("@messageReceiverGuard.check(#id)")
public void deleteByReceiver(Long id) {
    delete(id, Message::deleteByReceiver);
}
// PostService.java
@Transactional
@PreAuthorize("@postGuard.check(#id)")
public void delete(Long id) {
    Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new);
    deleteImages(post.getImages());
    postRepository.delete(post);
}

@Transactional
@PreAuthorize("@postGuard.check(#id)")
public PostUpdateResponse update(Long id, PostUpdateRequest req) {
    Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new);
    Post.ImageUpdatedResult result = post.update(req);
    uploadImages(result.getAddedImages(), result.getAddedImageFiles());
    deleteImages(result.getDeletedImages());
    return new PostUpdateResponse(id);
}

기존에 사용했던 Guard와 SpEL을 그대로 이용하였습니다.

Guard.check가 false를 반환한다면, AccessDeniedException이 발생하게 될 것입니다.

데이터베이스 접근이 필요한 Guard 구현체들은, @Transactional(readOnly=true)를 설정하여 트랜잭션이 유지되도록 설정해줍시다.

 

 

@PreAuthorize에서 던지는 AccessDeniedException은 Advice에서 잡아주도록 합시다.

package kukekyakya.kukemarket.advice;

import ...
import org.springframework.security.access.AccessDeniedException;

@RestControllerAdvice
@..
public class ExceptionAdvice {
    ...
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public Response accessDeniedException() {
        return ...;
    }

직접 정의했던 AccessDeniedException과 AuthenticationEntryPoint 예외는 제거해주었습니다.

Advice에서는 Security에 정의된 AccessDeniedException을 잡아낼 수 있도록 하고,

제거된 예외 AuthenticationEntryPoint를 캐치하던 코드도 제거해줍시다.

이에 알맞게 AuthenticationEntryPoint에 대한 exception.properties에서 예외 코드와 메시지도 지워주었습니다.

 

 

추상 클래스 Guard도 수정해주겠습니다. 

package kukekyakya.kukemarket.config.security.guard;

import ...

public abstract class Guard {
    public final boolean check(Long id) {
        return hasRole(getRoleTypes()) || isResourceOwner(id);
    }
    ...
}

어차피 인증된 사용자만 서비스 로직에 접근할 수 있으므로, Guard에서는 인증된 사용자인지 검사할 필요가 없어졌습니다. 

 

 

몇 가지 수정 사항을 더 살펴보겠습니다.

 

Guard 구현 클래스들의 로직도 수정해주겠습니다.

Guard.check의 반환 값에 따라 AccessDeniedException이 발생하는데, 굳이 이 예외를 던지도록 명시해줄 필요는 없습니다.

단순히 false를 반환시켜주면 됩니다.

// CommentGuard.java
@Override
protected boolean isResourceOwner(Long id) {
    return commentRepository.findById(id)
            .map(comment -> comment.getMember())
            .map(member -> member.getId())
            .filter(memberId -> memberId.equals(AuthHelper.extractMemberId()))
            .isPresent();
}
// MessageGuard.java
@Override
protected boolean isResourceOwner(Long id) {
    return messageRepository.findById(id)
            .map(message -> message.getSender())
            .map(sender -> sender.getId())
            .filter(senderId -> senderId.equals(AuthHelper.extractMemberId()))
            .isPresent();
}
// MessageReceiverGuard.java
@Override
protected boolean isResourceOwner(Long id) {
    return messageRepository.findById(id)
            .map(message -> message.getReceiver())
            .map(receiver -> receiver.getId())
            .filter(receiverId -> receiverId.equals(AuthHelper.extractMemberId()))
            .isPresent();
}
// MessageSenderGuard.java
@Override
protected boolean isResourceOwner(Long id) {
    return messageRepository.findById(id)
            .map(message -> message.getSender())
            .map(sender -> sender.getId())
            .filter(senderId -> senderId.equals(AuthHelper.extractMemberId()))
            .isPresent();
}
// PostGuard.java
@Override
protected boolean isResourceOwner(Long id) {
    return postRepository.findById(id)
            .map(post -> post.getMember())
            .map(member -> member.getId())
            .filter(memberId -> memberId.equals(AuthHelper.extractMemberId()))
            .isPresent();
}

올바르지 않은 접근이라면, Guard.check는 false를 반환하고 AccessDeniedException이 던져질 것입니다.

 

 

Spring Security에서 예외를 처리하던 방식도 수정해보겠습니다.

// SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            ...
            .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
            .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
            ...

Spring Security에서 접근이 거부되거나 인증이 되지 않았다면, CustomAccessDeniedHandler 또는 CustomAuthenticationEntryPoint가 작동하고 있습니다.

 

코드를 살펴봅시다.

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/exception/access-denied");
    }
}
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendRedirect("/exception/entry-point");
    }
}

/exception/access-denied 또는 /exception/entry-point로 리다이렉트되고 있습니다.

해당 URL로 리다이렉트되면, 직접 예외를 던져주고 RestControllerAdvice에서 해당 예외를 캐치하여 처리하고 있습니다.

단일화된 예외 관리를 위해 위와 같은 방식을 택했던 것입니다.

 

하지만 다시 생각해보면, 굳이 리다이렉트로 다시 요청을 보내야할지는 의문입니다.

상태코드만 즉시 응답해줘도 충분할 것이라 판단됩니다.

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(SC_FORBIDDEN);
    }
}
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(SC_UNAUTHORIZED);
    }
}

위와 같이 수정해주었습니다.

기존에 리다이렉트 URL을 처리하던 ExceptionController는 제거해주겠습니다.

 

 

 

ExceptionAdvice도 수정해주도록 하겠습니다.

기존의 코드에서는, ExceptionAdvice가 잡아낸 예외에 따라 예외 코드와 메시지를 응답해주고 있었습니다.

이 과정에서 MessageSource를 의존해야했고, exception.properties에 접근하기 위한 코드 값도 가지고 있어야만 했습니다.

예외 코드와 예외 메시지에 접근하기 위한 코드 값은, 별도의 enum에서 관리해주도록 하겠습니다.

package kukekyakya.kukemarket.exception.type;

import lombok.Getter;

@Getter
public enum ExceptionType {
    EXCEPTION("exception.code", "exception.msg"),
    AUTHENTICATION_ENTRY_POINT_EXCEPTION("authenticationEntryPointException.code", "authenticationEntryPointException.msg"),
    ACCESS_DENIED_EXCEPTION("accessDeniedException.code", "accessDeniedException.msg"),
    BIND_EXCEPTION("bindException.code", "bindException.msg"),
    LOGIN_FAILURE_EXCEPTION("loginFailureException.code", "loginFailureException.msg"),
    MEMBER_EMAIL_ALREADY_EXISTS_EXCEPTION("memberEmailAlreadyExistsException.code", "memberEmailAlreadyExistsException.msg"),
    MEMBER_NICKNAME_ALREADY_EXISTS_EXCEPTION("memberNicknameAlreadyExistsException.code", "memberNicknameAlreadyExistsException.msg"),
    MEMBER_NOT_FOUND_EXCEPTION("memberNotFoundException.code", "memberNotFoundException.msg"),
    ROLE_NOT_FOUND_EXCEPTION("roleNotFoundException.code", "roleNotFoundException.msg"),
    MISSING_REQUEST_HEADER_EXCEPTION("missingRequestHeaderException.code", "missingRequestHeaderException.msg"),
    CATEGORY_NOT_FOUND_EXCEPTION("categoryNotFoundException.code", "categoryNotFoundException.msg"),
    CANNOT_CONVERT_NESTED_STRUCTURE_EXCEPTION("cannotConvertNestedStructureException.code", "cannotConvertNestedStructureException.msg"),
    POST_NOT_FOUND_EXCEPTION("postNotFoundException.code", "postNotFoundException.msg"),
    UNSUPPORTED_IMAGE_FORMAT_EXCEPTION("unsupportedImageFormatException.code", "unsupportedImageFormatException.msg"),
    FILE_UPLOAD_FAILURE_EXCEPTION("fileUploadFailureException.code", "fileUploadFailureException.msg"),
    COMMENT_NOT_FOUND_EXCEPTION("commentNotFoundException.code", "commentNotFoundException.msg"),
    MESSAGE_NOT_FOUND_EXCEPTION("messageNotFoundException.code", "messageNotFoundException.msg"),
    REFRESH_TOKEN_FAILURE_EXCEPTION("refreshTokenFailureException.code", "refreshTokenFailureException.msg");

    private final String code;
    private final String message;

    ExceptionType(String code, String message) {
        this.code = code;
        this.message = message;
    }
}

exception.properties에 접근하기 위한 코드 값을 가지고 있습니다.

 

ExceptionType과 MessageSource를 이용하여 예외 코드와 예외 메시지로 변환해주는 책임도 별도의 객체로 위임해주겠습니다.

package kukekyakya.kukemarket.handler;

import ...

@Component
@RequiredArgsConstructor
public class ResponseHandler {
    private final MessageSource messageSource;

    public Response getFailureResponse(ExceptionType exceptionType) {
        return Response.failure(getCode(exceptionType.getCode()), getMessage(exceptionType.getMessage()));
    }

    public Response getFailureResponse(ExceptionType exceptionType, Object... args) {
        return Response.failure(getCode(exceptionType.getCode()), getMessage(exceptionType.getMessage(), args));
    }

    private Integer getCode(String key) {
        return Integer.valueOf(messageSource.getMessage(key, null, null));
    }

    private String getMessage(String key) {
        return messageSource.getMessage(key,null, LocaleContextHolder.getLocale());
    }

    private String getMessage(String key, Object... args) {
        return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
    }
}

예외 타입을 전달받으면, 적절한 Response를 생성하여 반환해줍니다.

 

 

ExceptionAdvice는 ResponseHandler만 의존해주도록 합시다.

package kukekyakya.kukemarket.advice;

import ...

@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class ExceptionAdvice {
    private final ResponseHandler responseHandler;

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Response exception(Exception e) {
        log.error("e = {}", e.getMessage());
        return getFailureResponse(EXCEPTION);
    }

    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public Response accessDeniedException() {
        return getFailureResponse(ACCESS_DENIED_EXCEPTION);
    }

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Response bindException(BindException e) {
        return getFailureResponse(BIND_EXCEPTION, e.getBindingResult().getFieldError().getDefaultMessage());
    }

    @ExceptionHandler(LoginFailureException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Response loginFailureException() {
        return getFailureResponse(LOGIN_FAILURE_EXCEPTION);
    }

    @ExceptionHandler(MemberEmailAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Response memberEmailAlreadyExistsException(MemberEmailAlreadyExistsException e) {
        return getFailureResponse(MEMBER_EMAIL_ALREADY_EXISTS_EXCEPTION, e.getMessage());
    }

    @ExceptionHandler(MemberNicknameAlreadyExistsException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Response memberNicknameAlreadyExistsException(MemberNicknameAlreadyExistsException e) {
        return getFailureResponse(MEMBER_NICKNAME_ALREADY_EXISTS_EXCEPTION, e.getMessage());
    }

    ...

    private Response getFailureResponse(ExceptionType exceptionType) {
        return responseHandler.getFailureResponse(exceptionType);
    }

    private Response getFailureResponse(ExceptionType exceptionType, Object... args) {
        return responseHandler.getFailureResponse(exceptionType, args);
    }
}

 

유사한 수정 작업이므로, 일부 코드는 지면을 위해 생략하겠습니다.

 

 

테스트도 알맞게 수정해주겠습니다.

ResponseHandlerTest를 작성해줍니다.

package kukekyakya.kukemarket.handler;

import ...

class ResponseHandlerTest {
    ResponseHandler responseHandler;

    @BeforeEach
    void beforeEach() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasenames("i18n/exception");
        responseHandler = new ResponseHandler(messageSource);
    }

    @Test
    void getFailureResponseNoArgsTest() {
        // given, when
        Response failureResponse = responseHandler.getFailureResponse(EXCEPTION);

        // then
        assertThat(failureResponse.getCode()).isEqualTo(-1000);
        assertThat(((Failure) failureResponse.getResult()).getMsg()).isEqualTo("오류가 발생하였습니다.");
    }

    @Test
    void getFailureResponseWithArgsTest() {
        // given, when
        Response failureResponse = responseHandler.getFailureResponse(BIND_EXCEPTION, "my args");

        // then
        assertThat(failureResponse.getCode()).isEqualTo(-1003);
        assertThat(((Failure) failureResponse.getResult()).getMsg()).isEqualTo("my args");
    }
}

ResponseHandler를 테스트하면서 MessageSource의 동작 여부도 간단하게 테스트해주겠습니다.

 

 

XXXControllerAdviceTest에서는, MessageSource를 주입해주던 기존의 방식에서,

Mock으로 만든 ResponseHandlerTest를 주입해주도록 하겠습니다.

@ExtendWith(MockitoExtension.class)
class CategoryControllerAdviceTest {
    @InjectMocks CategoryController categoryController;
    @Mock CategoryService categoryService;
    @Mock ResponseHandler responseHandler;
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(categoryController).setControllerAdvice(new ExceptionAdvice(responseHandler)).build();
    }

각각의 테스트에서 예외 코드 검증은 제거해주었습니다.

이에 대한 코드는 생략하겠습니다.

 

다른 XXXControllerAdviceTest에 대한 수정 코드도, 유사한 작업이므로 생략하겠습니다.

 

XXXControllerIntegrationTest에서 3xx 상태코드를 응답하던 권한 검증 코드도, 401 또는 403을 알맞게 응답하는지 확인하도록 수정해주겠습니다.

 

이에 대한 부분은 테스트를 진행하면서 직접 수정해나갈 수 있으므로, 코드는 생략하겠습니다.

제거된 코드에 대한 테스트도 지워주도록 합시다.

모든 테스트 성공

잘 수행되었는지 확인해봅시다.

 

 

이번 시간에는 권한에 따른 기존 접근 방식의 문제점을 살펴보고, 이를 개선하였습니다.

이제 접근 제어 코드는, 서비스 로직과 분리하면서도 단일 트랜잭션으로 처리할 수 있게 되었습니다.

이 외에도 개선이 필요한 코드를 리팩토링하였고, 이에 알맞게 테스트도 수정해주었습니다.

책임이 분리되면서 각 객체는 더욱 간결해졌고, 테스트도 용이해졌습니다.

 

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

 

반응형

+ Recent posts