반응형

이번 시간에는 Spring Security를 이용하여 로그인된 사용자를 인증하고, 요청한 자원에 접근할 수 있는지 검증해보도록 하겠습니다. 

 

 

일단, 지금은 회원가입과 로그인에 대한 API만 구현된 상태입니다.

사용자 인증 정보와 권한에 따른 요청을 검증할 수 있는 API가 없으므로,

간단하게 사용자를 조회하고 삭제하는 API를 먼저 구현해보도록 하겠습니다.

 

service.member 패키지에 MemberService 클래스를 작성해줍니다.

package kukekyakya.kukemarket.service.member;

import kukekyakya.kukemarket.dto.member.MemberDto;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.exception.MemberNotFoundException;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberDto read(Long id) {
        return MemberDto.toEntity(memberRepository.findById(id).orElseThrow(MemberNotFoundException::new));
    }

    @Transactional
    public void delete(Long id) {
        if(notExistsMember(id)) throw new MemberNotFoundException();
        memberRepository.deleteById(id);
    }

    private boolean notExistsMember(Long id) {
        return !memberRepository.existsById(id);
    }

}

단순히 MemberRepository를 이용해서 요청한 사용자의 정보를 조회하고 삭제하는 API 입니다.

* 없는 id에 대해서 deleteById를 하게 되면, EmptyResultDataAccessException 예외가 발생하지만, 예외 종류의 간소화를 위해 직접 id가 존재하는지 확인해주었습니다. 코드를 간소화시키고 싶다면, 검증 로직 없이 deletebyId를 수행해서 해당 예외를 잡아내도 됩니다.

 

 

* delete 로직은, 18편에서 개선될 예정입니다. 결론적으로 말하면, deleteById 메소드는 내부적으로 delete 메소드를 호출하게 됩니다. findById 메소드를 수행한 뒤에, delete 메소드에 인자로 넘겨주는 것입니다. 따라서 existsById에 의해서 SELECT 쿼리가 한번 나가고, deleteById에서 findById에 의해 SELECT 쿼리가 한번 나가면, 동일한 용도의 쿼리가 두 번 수행되는 것입니다. deleteById를 이용하면, 단일한 DELETE 쿼리가 나갈 것이라는 기대와는 다른 동작이었습니다.

이를 개선하면,

@Transactional
public void delete(Long id) {
    Member member = memberRepository.findById(id).orElseThrow(...);
    memberRepository.delete(member);
}

다음과 같은 형태로 코드를 작성할 수 있을 것입니다. 그러면 SELECT 쿼리는 한 번만 수행할 수 있게 됩니다. 자세한 내용은 18편에서 다루도록 하고, 그 이전까지는 현재의 코드를 유지하면서 진행하겠습니다.

 

 

 

dto.member 패키지에 조회된 사용자 정보를 나타내는 MemberDto 클래스를 작성하겠습니다.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MemberDto {
    private Long id;
    private String email;
    private String username;
    private String nickname;

    public static MemberDto toDto(Member member) {
        return new MemberDto(member.getId(), member.getEmail(), member.getUsername(), member.getNickname());
    }
}

사용자 정보를 나타내기 위한 id, 이메일, 사용자 이름, 닉네임을 가지고 있습니다.

엔티티를 dto로 변환하기 위한 스태틱 메소드도 하나 작성해줍니다.

 

 

MemberRepository에 대한 테스트는 지난 시간에 이미 수행했으므로,

MemberService에 대한 테스트만 새롭게 작성해보도록 하겠습니다.

test 디렉토리에서 동일한 패키지 경로 내에 MemberServiceTest를 작성해줍니다.

package kukekyakya.kukemarket.service.member;

import kukekyakya.kukemarket.dto.member.MemberDto;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.exception.MemberNotFoundException;
import kukekyakya.kukemarket.repository.member.MemberRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
    @InjectMocks MemberService memberService;
    @Mock MemberRepository memberRepository;

    @Test
    void readTest() {
        // given
        Member member = createMember();
        given(memberRepository.findById(anyLong())).willReturn(Optional.of(member));

        // when
        MemberDto result = memberService.read(1L);

        // then
        assertThat(result.getEmail()).isEqualTo(member.getEmail());
    }

    @Test
    void readExceptionByMemberNotFoundTest() {
        // given
        given(memberRepository.findById(any())).willReturn(Optional.ofNullable(null));

        // when, then
        assertThatThrownBy(() -> memberService.read(1L)).isInstanceOf(MemberNotFoundException.class);
    }

    @Test
    void deleteTest() {
        // given
        given(memberRepository.existsById(anyLong())).willReturn(true);

        // when
        memberService.delete(1L);

        // then
        verify(memberRepository).deleteById(anyLong());
    }

    @Test
    void deleteExceptionByMemberNotFoundTest() {
        // given
        given(memberRepository.existsById(anyLong())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> memberService.delete(1L)).isInstanceOf(MemberNotFoundException.class);
    }


    private Member createMember() {
        return new Member("email@email.com", "123456a!", "username", "nickname", List.of());
    }
}

이제 테스트에 대해 익숙해지고 있으므로, 코드에 대한 자세한 설명은 생략하겠습니다.

SignService.read와 SignService.delete를 수행할 때의 정상적인 상황과 예외 상황에 대해 테스트해주었습니다.

 

 

이제 API 요청을 할 수 있도록 controller.member 패키지에 MemberController 를 작성해보겠습니다.

@RestController
@RequiredArgsConstructor
@Slf4j
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/api/members/{id}")
    @ResponseStatus(HttpStatus.OK)
    public Response read(@PathVariable Long id) {
        return Response.success(memberService.read(id));
    }

    @DeleteMapping("/api/members/{id}")
    @ResponseStatus(HttpStatus.OK)
    public Response delete(@PathVariable Long id) {
        memberService.delete(id);
        return Response.success();
    }
}

사용자의 id로 조회와 삭제 작업을 수행할 수 있는 두 개의 API입니다.

* DELETE 요청은 응답해야할 실질적인 데이터가 없다면, 상태코드로 204 (No Content)를 응답해도 되지만, 우리의 서비스에서는 응답의 일관성을 위해 Response.success()를 응답하고 있으므로, 아예 응답 데이터가 없다고 보긴 어렵습니다. 따라서, 상태코드 200 (OK)를 응답해주도록 하겠습니다.

 

 

바로 테스트도 진행해보겠습니다.

test 디렉토리에서 동일한 패키지 경로에 MemberControllerTest를 작성해줍니다.

@ExtendWith(MockitoExtension.class)
class MemberControllerTest {
    @InjectMocks MemberController memberController;
    @Mock MemberService memberService;
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(memberController).build();
    }

    @Test
    void readTest() throws Exception {
        // given
        Long id = 1L;

        // when, then
        mockMvc.perform(
                get("/api/members/{id}", id))
                .andExpect(status().isOk());
        verify(memberService).read(id);
    }

    @Test
    void deleteTest() throws Exception {
        // given
        Long id = 1L;

        // when, then
        mockMvc.perform(
                delete("/api/members/{id}", id))
                .andExpect(status().isOk());
        verify(memberService).delete(id);
    }

}

자세한 설명은 생략하겠습니다.

 

 

이어서 Advice 동작에 대해 테스트도 진행해보겠습니다.

동일한 패키지 경로 내에 MemberControllerAdviceTest를 작성해줍니다.

@ExtendWith(MockitoExtension.class)
public class MemberControllerAdviceTest {
    @InjectMocks
    MemberController memberController;
    @Mock
    MemberService memberService;
    MockMvc mockMvc;

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

    @Test
    void readMemberNotFoundExceptionTest() throws Exception {
        // given
        given(memberService.read(anyLong())).willThrow(MemberNotFoundException.class);

        // when, then
        mockMvc.perform(
                get("/api/members/{id}", 1L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1007));
    }

    @Test
    void deleteMemberNotFoundExceptionTest() throws Exception{
        // given
        doThrow(MemberNotFoundException.class).when(memberService).delete(anyLong());

        // when, then
        mockMvc.perform(
                delete("/api/members/{id}", 1L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value(-1007));
    }

}

테스트는 이제 익숙하기에, 이번에도 자세한 설명은 생략하도록 하겠습니다.

상태 코드 뿐만 아니라, 직접 정의해둔 응답 코드도 검증해보았습니다.

* 앞으로 Advice를 테스트할 때는 응답 코드도 모두 검증하도록 하겠습니다. 이전에 작성했던 SignControllerAdviceTest도 응답 코드를 검증할 수 있도록 수정하면 되겠습니다.

 

 

 

이제 위에서 작성된 MemberController의 두 가지 API를 가지고 인증과 인가의 필요성에 대해 알아보도록 하겠습니다.

사용자 정보를 조회하는 GET 요청은, 누구나 조회해도 됩니다. 즉, 자원에 대한 접근 제어가 필요하지 않습니다.

하지만 사용자 정보를 삭제하는 DELETE 요청은, 특정한 사람만 접근할 수 있어야합니다.

예를 들어, 어떤 인증되지 않은(로그인을 안했든) 사용자가 누군가의 정보를 삭제할 수 있어서는 안됩니다.

만약 로그인되었더라도, 자신의 회원 정보가 아니라면, 삭제할 수 없어야합니다.

A라는 사용자가 로그인했는데, A의 인증 정보로 B라는 사용자의 정보를 삭제할 수 있는 권한을 얻게 된다면, 아주 큰 위험이 생기게 됩니다. 

또, 관리자는 자신의 회원 정보가 아니더라도, 누구의 회원 정보든 삭제할 수 있어야합니다. (실제로 이래도 되는진 모르겠지만, 우리의 서비스에서는 관리자가 모든 행위를 수행할 수 있다고 가정하겠습니다.)

 

정리하면, 수정이나 삭제와 같이 아무나 요청할 수 없어야하는 경우,

(자원의 소유자가 요청자 본인) 이거나 (관리자) 인지 검증이 되어야만, 그 자원에 접근할 수 있어야합니다.

그렇기에 사용자가 누구인지 인증하고, 그 인증 정보에 따라서 자원에 접근을 제어(인가)해줘야합니다.

 

그렇다면, 이러한 인증과 인가는 어느 시점에 수행해야할까요?

 

우리의 서비스에 사용자가 로그인을 하면, 사용자의 정보가 포함된 토큰을 발급해줍니다. 

또, 우리는 Spring Security를 이용하고 있습니다.

Spring Security를 이용하면, 자동으로 관련 설정들이 활성화되고, 사용자의 요청은 필터 체인을 거치게 됩니다.

이 필터 체인에다가, 우리가 발급한 토큰을 검증할 수 있는 필터를 추가하고,

토큰을 통해 조회한 사용자 정보(사용자의 권한 등급 포함)를 Security에서 관리해주는 컨텍스트에 저장해줄 것입니다.

정상적인 사용자의 요청이 토큰으로 인해 인증되었다면, 필터 체인을 거치면서 우리가 설정한 사용자 정보가 컨텍스트에 저장되어있을 것입니다.

이렇게 얻은 인증 정보로, 사용자의 권한 등급을 통해 자원에 접근할 수 있는지를 검사해주면 됩니다.

 

하지만 자원에 대한 접근 허용 여부는, 단순히 사용자가 가진 권한 등급을 통해서만 이루어질 수 없습니다.

관리자 권한 등급만 있으면 접근할 수 있는 요청도 있겠지만,

위에서 서술했다시피, 관리자 권한 등급이 있거나 요청자가 자원의 소유주일 때만 자원에 접근할 수 있는 요청도 있습니다.

즉, 요청 종류에 따라 접근 허용 여부를 세밀하게 검증할 필요성이 있다는 것입니다.

 

간단한 예시를 들어보겠습니다.

우리의 서비스에서 구현할 내용처럼, 자원의 소유주이거나 관리자라면 요청을 수행할 수 있는 것으로 가정하겠습니다.

우리는 23번 게시글을 삭제하는 요청을 보내고자 합니다.

DELETE /api/posts/23

로그인한 사용자가, 위와 같은 삭제 요청을 전송하였습니다.

이러한 게시글을 삭제하기 위해서는 몇 가지 검증이 필요합니다.

1. 인증된(로그인된) 사용자여야합니다.

2. 요청을 보낸 사용자가, 23번 게시글의 소유주(작성자)여야합니다.

3. 23번 게시글의 소유주가 아니라면, 관리자여야합니다.

그렇다면, 여기에서 하나의 의문이 생길 수도 있습니다.

2번과 3번에 대한 검증은 어느 시점에 해야할까요?

일단, 인증된 사용자는 직접 설정한 필터를 거치면서, 사용자 정보를 가지게 됩니다.

즉 3번은 Spring Security를 거치면서 조회된 권한 등급으로 간단히 처리할 수 있습니다.

하지만 2번 조건을 검증하려면, 요청된 게시글의 id값을 통해 데이터베이스에서 조회한 뒤,

현재 API 요청자와 게시글의 작성자가 동일한 사용자인지 검증해줘야합니다.

만약 이러한 검증을, 서비스 계층에서 해준다고 보겠습니다.

게시글 삭제 로직을 처리하면서, 동시에 API 요청 자체에 대한 인가 로직까지 포함하게 됩니다.

 

간단한 코드로 예시를 들어보겠습니다.

// PostService.java

@Transactional
public void delete(Long memberId, Long postId) {
    Post post = postRepository.findById(postId).orElseThrow(...);
    if(post.getWriter().getId().equals(memberId)) {
        postRepository.delete(post);
    }
    throw new AccessDeniedException();
}

서비스 로직에서 게시글 데이터를 조회하고, 요청자인 memberId와 게시글의 작성자 id를 조회하여,

요청자가 자신의 게시글에 대한 삭제 요청을 보낸 것인지 검사하게 됩니다.

하지만, 위 코드가 정상적으로 동작할까요?

정상적으로 동작하지 않습니다.

만약, 관리자가 요청을 보낸다면, 관리자는 조회된 게시글의 작성자가 아닙니다.

그렇기에 if문 조건에 들어가지 못하게 되고, 예외가 발생하게 됩니다.

분명 우리는, 요청자가 게시글 작성자일 때 뿐만 아니라, 관리자일 때도 게시글을 삭제할 수 있어야합니다.

 

이 또한 검증하기 위해서는 코드는 다음과 같이 수정됩니다.

// PostService.java

@Transactional
public void delete(Long memberId, Long postId) {
    Post post = postRepository.findById(postId).orElseThrow(...);
    Member member = memberRepository.findById(memberId).orElseThrow(...);
    if(post.getWriter().getId().equals(memberId) || member.hasRole(ROLE_ADMIN)) {
        postRepository.delete(post);
    }
    throw new AccessDeniedException();
}

이제 요청자가 작성자 본인이거나 관리자 권한 등급을 가지고 있다면, 게시글 삭제를 수행할 수 있게 됐습니다.

간단해보이지만, 이상한 점이 하나 있습니다.

이미 권한 등급에 따른 검증은 Security에서 수행할 수 있었습니다.

그런데, 요청자 작성자 일치 여부를 서비스 로직에서 검증하다보니, 권한 등급에 따른 검증을 여기에서 한번 더 수행하고 있는 것입니다.

만약, 관리자이면서(AND) 일치 여부를 검사한다면, 중복해서 검사할 필요가 없겠지만,

관리자이거나(OR) 일치 여부를 검사해야하기때문에, 중복해서 검사하게 되는 것입니다.

결국, 두 개의 조건은 같이 갈 수 밖에 없게 됩니다.

 

그렇다면, Security에서 권한 등급에 따른 접근 제어 여부를 검사하지 않으면 되는 것 아닐까요?

사실 이러한 문제 뿐만 아니라, 책임의 문제도 있습니다.

서비스 계층은 보통 비즈니스 로직을 담당합니다.

우리의 서비스는 인증 서비스가 아닙니다.

사람에 따라 관점은 다르겠지만, 자원의 소유주를 검증하는 작업은,

게시글 처리를 담당할 비즈니스 로직이라고 보긴 어렵습니다.

우리는 분명 Spring Security를 사용하여 인증 및 인가에 대한 계층을 따로 구축하고 있습니다.

컨트롤러 계층으로 들어오기 전에, 잘못된 요청은 미연에 차단하고 있는 것입니다.

 

그런데, Security에서 처리할 일을 서비스 로직에서까지 함께 담당한다면,

서비스 계층이 보안에 대한 책임도 덩달아 맡게 되면서 책임은 분산되고, Security 계층의 존재 자체가 무색해집니다.

 

물론, 지금은 삭제에 대한 간단한 요청입니다.

하지만, 자원 별로 수정이나 삭제 등 다양한 서비스 로직이 추가되면서,

각 서비스 로직마다, 각 서비스의 메소드마다, 접근 허용 여부에 대한 보안 관련 로직으로 범벅되어버립니다. 

위에서 작성한 PostService.delete는 리포지토리 의존성을 이용하여 게시글 삭제를 담당합니다.

서비스 로직에서 자원의 소유주 검사도 수행하게 된다면,

기존의 코드를 자신의 책임과 관련 없는 접근 제어 정책에 따라 수정해주면서 새로운 책임을 부여하는 상황이 만들어집니다.

 

이러한 보안 관련 로직을 제거하고, 순수 게시글 삭제만을 처리하는 담당한다면 코드는 어떻게 될까요?

// PostService.java

@Transactional
public void delete(Long postId) {
    postRepository.deleteById(postId);
}

단순히 삭제만 수행하면 됩니다.

 

 

물론, 이렇게 작성하면 책임은 분산되고 코드 관리 측면에서 더욱 편리해지지만, 한 가지 문제가 생기게 됩니다.

트랜잭션을 한번 더 열어야한다는 것입니다.

서비스 로직에서 자원 소유주를 검사한다면, 서비스에서 열리는 한번의 트랜잭션으로 모든 것을 처리할 수 있습니다.

하지만 Security에서 자원 소유주를 검사한다면, 요청된 게시글의 id를 가지고 게시글을 조회하는 작업을 거쳐야합니다.

즉, 서비스 로직에 도달하기 전에도, Security에서 트랜잭션을 한번 열어야한다는 것입니다.

Security에서 게시글 조회 1번 + 서비스 로직에서 게시글 삭제 1번 = 총 2번의 트랜잭션을 수행하게 됩니다.

이를 방지하기 위해 Security에서 열어둔 트랜잭션을 계속 유지하는 방법도 있겠지만(어떻게 하는진 생각 안해봤음. 구상만.),

이렇게 할 경우 트랜잭션을 너무 오래 물고있어야하기도 하고, 요청을 처리하는 컨트롤러 계층에서도 트랜잭션이 유지되고 있어서 어떤 문제가 생길지 모릅니다.

 

하지만 저는, 트랜잭션을 한번 더 여는 것이 크게 문제될 것이라고 보진 않았습니다.

1번을 서비스 계층에서 자원의 소유주 또는 관리자 검증,

2번을 Security에서(컨트롤러에 요청이 도달하기 전) 자원의 소유주 또는 관리자 검증이라고 하겠습니다.

 

* 추가) 결국 문제될 것이라 보고,

2022.01.08 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (37) - API 접근 제어 방식 수정

에서 트랜잭션은 한 번만 수행하도록 수정하였습니다.

혹시 따라하시는 분이 계시다면, 12편까지 로그인 과정을 다 마친 뒤에 수정해도 전혀 지장은 없습니다. 

 

먼저, 성능 관점에서 생각해보겠습니다.

결론부터 말하면, 정상적인 요청이 많을 경우에는 1번이 유리할 것이고, 잘못된 요청이 많을 경우에는 2번이 유리할 것입니다.

 

아까 말했듯이, 1번은 서비스 계층에서 한번의 트랜잭션으로 모두 처리할 수 있지만,
2번은 Security에서 자원의 소유 여부 검사 1번, 서비스 계층에서 비즈니스 로직 처리 1번을 거치면서 두 번의 트랜잭션으로 처리하게 됩니다.

트랜잭션을 한번 더 연다는 것은 분명 새로운 비용이 생기게 됩니다.

 

하지만 둘 다 상황에 따라 성능상의 이점도 있을 것이고, 단점도 있을 것 입니다.

1번의 경우에 정상적인 요청이라면 서비스 계층까지 요청이 도달하게 되고,

비정상적인 요청이더라도 동일하게 서비스 계층까지 요청이 도달하게 됩니다.

2번의 경우에 정상적인 요청이라면 서비스 계층까지 요청이 도달하게 되고,

비정상적인 요청이라면 Security를 통해 요청이 조기에 차단됩니다.


정상적인 처리라면 서비스 이용량에 따라 계측이 되어 유연하게 대응할 수 있을 것이지만,

악의적인 공격이 있다면, 요청이 더욱 조기에 차단되는 것이 유리할 것입니다.

따라서 성능 관점에서는 상황에 따른 대응에 유리한 2번을 선택하게 되었습니다.

또, 단순히 id 값으로 데이터베이스를 조회하는 단일한 쿼리를 수행하는 경우가 대부분이므로, 트랜잭션을 그렇게 오래 물고 있지 않을 것입니다.

(물론, 비정상적인 요청은 더 앞에서 차단할 수 있어야겠지만요. 그냥 고민 과정 중 하나라고 봐주시면 감사하겠습니다.)

 

이번에는 책임 관점에서 보겠습니다.
1번으로 할 경우, 자원 소유주 또는 관리자 여부 등 접근 권한에 관한 부분은, 결국 서비스 계층에서 처리하게됩니다.

서비스 계층의 책임은 많아지고, Security의 책임은 분산되면서 Security의 존재 자체가 무색해지는 것 입니다.
2번으로 할 경우, 서비스 로직에서는 접근 제어 등 보안 로직과 관계 없는 본연의 로직만을 처리할 수 있게 됩니다.

이를 통해 책임을 줄이고 코드를 깨끗하게 유지할 수 있습니다.

 

이번에는 코드 관점에서 보겠습니다.

책임이 줄어들면, 코드도 분명해지고 가독성이 좋아지게 됩니다.

1번으로 할 경우, 어떤 자원을 요청할 때마다 각 메소드 로직에서는 동일한 검증 로직이 중복해서 나타나게 됩니다.

세밀한 제어가 필요하지 않다면, 서비스 로직에서만 처리해도 코드는 나름 깔끔하게 유지되겠지만,

세밀한 제어가 필요하게 된다면, 서비스 로직은 접근 제어 로직으로 오염되고 말 것입니다.

2번으로 할 경우, 다른 곳에서 접근 허용 여부 검사라는 본연의 책임에만 집중할 수 있으므로, 코드는 깔끔하게 유지되고 관리성 측면에서 더욱 이점을 얻게 됩니다.

 

 

이러한 까닭에, 저는 2번을 선택하였습니다.

지금 구현하고자 하는 기능 관점에서 바라보면,

자원의 소유주 검사 또는 관리자 권한 검사를 서비스 계층에서 수행하지않고, 모두 Security를 거치면서 처리하도록 한 것입니다.

 

 

* 사실 간단히 언급하고 넘어갈 수도 있었지만, 인터넷에 작성된 대부분의 코드나 주변 사람에게 자문을 구해보았을 때, 다들 이러한 자원에 대한 소유주 검사는 서비스 계층에서 처리한다고 하셨습니다.

이것도 분명 충분히 이해는 되지만, 서비스 계층에서 보안 로직을 담당하게 되는 것이 그렇게 와닿지가 않았습니다.

그래서 나름 고민을 해보게 되었고, 그에 대한 고민 과정과 결과를 작성해보았습니다.

사실, 이러한 Security 책임도 두 가지로 분류되면 더 명확할 것이라고 봅니다.

- 사용자의 요청 자체에 대한 보안(예를 들어, 권한 등급과 같은 사용자 인증 정보로 처리할 수 있는 검증)과,

- 사용자의 요청을 비즈니스 로직과 연관시킨 보안(예를 들어, 요청자가 자원의 소유자인지 검증).

트랜잭션의 문제를 해결하기 위해서, 후자와 같이 데이터베이스 접근이 필요한 인가 작업은, 컨트롤러와 서비스 계층 사이에 새로운 보안 계층을 구축하는 것도 하나의 방법일 것이라고 생각 됩니다. 물론 개인적인 생각일 뿐입니다.

지금은 단일한 프로젝트에서 모놀리틱하게 개발되고 있으므로 위에서 논의된 방식을 택할 것이고, 지금 언급하는 보안에 대한 두 가지 관점의 책임을 하나의 책임으로 묶을 것입니다.

 

 

 

이번 시간에는 인증 및 인가 로직을 코드로 작성하기 전에 다음을 수행하였습니다.

- 간단한 사용자 조회 및 삭제 API 작성

- 설계에 대한 논의

 

다음 시간에는, 실제 코드를 작성하면서 기능 구현을 진행해보도록 하겠습니다.

 

 

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

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

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

반응형

+ Recent posts