[프로젝트1] 7. 게시글 관련 Front 로직 변경하기, admin 계정추가, Exception와 Specification

rin·2020년 5월 6일
0
post-thumbnail
post-custom-banner

목표
1. 게시글 작성 등과 같은 UI를 세션 사용에 맞춰 변경한다.
2. 어드민 계정을 추가하고 일반 계정과 다른 Role을 적용하도록 한다.
3. Specification(명세)를 추가하여 validation을 할 수 있도록 한다.
4. validation의 결과로 Exception를 통해 client에 결과를 전달하도록 변경한다.

1. Front View와 로직 변경하기

이전 게시글에서 미처 변경하지 못했던 내용들을 수정할 것이다.

세션을 이용해 로그인한 유저 정보를 가져오므로 BoardForm에는 게시글 제목과 내용만 있으면 된다.

@Getter
@NoArgsConstructor
public class BoardForm {

    private String contents;
    private String title;

    @Builder
    public BoardForm(String contents, String title){
        this.contents = contents;
        this.title = title;
    }

    public BoardEntity convertBoardEntity(UserEntity user){
        return BoardEntity.builder()
                .writer(user)
                .contents(this.contents)
                .title(this.title)
                .build();
    }
}

게시글 목록에서 글 작성 날짜를 볼 수 있도록 BoardDto에 createdAt 컬럼을 추가한다.

@NoArgsConstructor
@Getter
public class BoardDto{

    private long id;
    private UserDto writer;
    private String contents;
    private String title;
    private LocalDateTime createdAt;

    public BoardDto(BoardEntity board) {
        this.writer = UserDto.of(board.getWriter());
        this.id = board.getId();
        this.contents = board.getContents();
        this.title = board.getTitle();
        this.createdAt = board.getCreatedAt();
    }

    public static BoardDto of(BoardEntity board) {
        return new BoardDto(board);
    }
}

table.hbs

<script id="tableList" type="text/x-handlebars-template">
    <table class="table table-striped" style="width: 50% !important;">
        <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">제목</th>
            <th scope="col">글쓴이</th>
        </tr>
        </thead>
        <tbody>
        \{{#contents}}
        <tr onclick='fetchDetails(\{{@index}})'>
            <th scope="row">\{{math @index '+' 1}}</th>
            <td>\{{title}}</td>
            <td>\{{writer.accountId}}</td>
        </tr>
       \{{/contents}}
        </tbody>
    </table>
</script>

작성자 정보를 UserDto의 형태로 받으므로 writer의 attribute인 accountId를 글쓴이로 보여주도록 변경하였다.

modal.hbs

<script id="writeModal" type="text/x-handlebars-template">
    <div class="modal fade" id="boardModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="exampleModalLabel">New message</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <form>
                        <div class="form-group">
                            <label for="recipient-name" class="col-form-label">Writer:</label>
                            <input type="text" class="form-control" id="user" disabled="disabled" value="\{{this}}" required />
                        </div>
                        <div class="form-group">
                            <label for="message-text" class="col-form-label">Title:</label>
                            <textarea class="form-control" id="title" required></textarea>
                        </div>
                        <div class="form-group">
                            <label for="message-text" class="col-form-label">Contents:</label>
                            <textarea class="form-control" id="contents" required></textarea>
                        </div>
                        <input type="submit" id="requiredBtn" style="visibility: hidden;" />
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal" id="closeBtn">Close</button>
                    <button type="button" class="btn btn-primary" id="saveBtn">Write</button>
                    <button type="button" class="btn btn-primary" id="deleteBtn" style="display: none">Delete</button>
                </div>
            </div>
        </div>
    </div>
</script>

패스워드를 입력하는 div를 제거하였다.
userId를 입력받는 inputdisabled='disabled'를 이용해 작성자 명을 바꾸지 못하도록 하였다. 처음 이 모달 템플릿을 붙일 때 데이터로 세션에서 받아온 accountId를 넘기는데 이를 해당 태그의 value에 넣음으로써 자동으로 셋팅되게 하였다.
required를 활성화시키기 위해선 같은 form 태그 내에 submit 버튼이 존재해야하기 때문에, style='visibility: hidden;'을 이용하여 보이지 않도록 하였다.
saveBtn 버튼을 누르면 requiredBtn 이 눌리게 되고 이 때 필수 인풋 중 비어있는게 있는지 확인하도록 js 로직을 작성할 것이다.

board.hbs

{{#partial "header"}}
    <title>Main Page</title>
{{/partial}}

<!--body-->
{{#partial "contents"}}
    <h1>이곳은 게시판입니다.</h1>
    {{#if accountId}}
    <div>로그인한 계정 : {{accountId}} <button onclick='location.href="logout"' class='btn btn-primary'>로그아웃</button></div>
    {{/if}}
    <div id="tableSpace"></div>
    <div id="pageMarkerSpace"></div>
    <div id="writeModalSpace"></div>
    {{#if accountId}}
        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#boardModal">글쓰기</button>
    {{/if}}
{{/partial}}
<!--body-->

<!--js-->
{{#partial "js"}}
    {{> template/table}}
    {{> template/pageMarker}}
    {{> template/modal}}
    <script>
        let nowBoardList = new Object();
        let nowBoardIndex = 0;
        let accountId = '{{accountId}}';
        const WRITE = "Write";
        const MODIFY = "Modify";

        window.onload = () => {
            apiRequest(attachBoard);
            attachWriteModal();
        }

        var attachWriteModal = () => {
            var template = Handlebars.compile($("#writeModal").html());
            $("#writeModalSpace").html(template(accountId));
        }

        var apiRequest = (callback = null, page = 1) => {
            const SIZE = 10;
            $.ajax({
                method : 'GET',
                url : 'api/boards?page='+page+"&size="+SIZE
            }).done(function (response) {
                nowBoardList = response.contents;
                if(typeof callback != 'undefined'){
                    callback(response);
                }
            })
        }

        var attachBoard = (response) => {
            if(typeof response != 'undefined') {
                var template = Handlebars.compile($("#tableList").html());
                $("#tableSpace").html(template(response));
                var template = Handlebars.compile($("#pageMarker").html());
                $("#pageMarkerSpace").html(template(response));
            }
        }

        var pageConvert = (page) => {
            apiRequest(attachBoard, page);
        }

        var fetchDetails = (index) => {
            nowBoardIndex = index;
            setModalData(index);
            setModifyModal();
            $('#boardModal').modal("show");
        }

        var setModalData = (index) => {
            var $modal = $('#boardModal');
            $modal.find('#user').val(nowBoardList[index].writer.accountId);
            $modal.find('#title').val(nowBoardList[index].title);
            $modal.find('#contents').val(nowBoardList[index].contents);
        }

        var setModifyModal = () => {
            var $modal = $('#boardModal');
            $modal.find('#saveBtn').text(MODIFY);
            if($modal.find('#user').val() === accountId){
                $modal.find('#deleteBtn').css('display', 'block');
            }else{
                setDisableContents($modal);
            }
        }

        var setDisableContents = ($modal) => {
            $modal.find('#title').attr('disabled', true);
            $modal.find('#contents').attr('disabled', true);
            $modal.find('#saveBtn').css('display','none');
        }

        var setAbleContents = ($modal) => {
            $modal.find('#title').attr('disabled', false);
            $modal.find('#contents').attr('disabled', false);
            $modal.find('#saveBtn').css('display', 'block');
        }

        var resetModalData = () => {
            var $modal = $('#boardModal');
            $modal.find('#user').val(accountId);
            $modal.find('#title').val("");
            $modal.find('#contents').val("");
        }

        var setNewWriteModal = () => {
            var $modal = $('#boardModal');
            $modal.find('#saveBtn').text(WRITE);
            $modal.find('#deleteBtn').css('display', 'none');
            setAbleContents($modal);
        }

        var setBoardForm = () => {
            var boardForm = new Object();
            var $modal = $('#boardModal');
            boardForm.title = $modal.find('#title').val();
            boardForm.contents = $modal.find('#contents').val();
            return boardForm;
        }

        var requiredInputHaveNullSpec = (boardForm) => {
            var spec = false;
            $.each(boardForm, function (attribute, value) {
                if(value == null || value.trim() == ""){
                    spec = true;
                    return false;
                }
            })
            return spec;
        }

        $(document).on('show.bs.modal', '#boardModal', function (event) { // 모달 열릴 때 이벤트

        })

        $(document).on('hidden.bs.modal', '#boardModal', function () { // 모달 닫힐 때 이벤트
            resetModalData();
            setNewWriteModal();
        })

        $(document).on('click', '#deleteBtn', function() {
            var password = $('#boardModal').find('#password').val();
            $.ajax({
                method : 'DELETE',
                url : 'api/boards/'+nowBoardList[nowBoardIndex].id+'?password='+password
            }).done(function (response) {
                if(typeof response != 'undefined'){
                    if(response == false) {
                        alert(" 비밀 번호가 틀렸습니다. ");
                    } else {
                        $('#boardModal').modal("hide"); //닫기
                    }
                }
            })
        })

        $(document).on('click', '#saveBtn', function() {
            $('#requiredBtn').click();
            var boardForm = setBoardForm();
            if(requiredInputHaveNullSpec(boardForm) == true){
                return false;
            }
            var method, url;
            if($('#saveBtn').text() === WRITE){
                method = 'POST';
                url = 'api/boards';
            }else{
                method = 'PUT';
                url = 'api/boards/'+nowBoardList[nowBoardIndex].id;
            }

            $.ajax({
                method : method,
                url : url,
                contentType: 'application/json',
                data : JSON.stringify(boardForm)
            }).done(function (response) {
                if(typeof response != 'undefined'){
                    if(response == false){
                        alert(" 비밀 번호가 틀렸습니다. ");
                    }else {
                        $('#boardModal').modal("hide"); //닫기
                    }
                }
            })
        })

    </script>
    {{#block "helper"}}{{/block}}
{{/partial}}
<!--js-->
{{> static/helper/helper}}
{{> layout/layout}}

마지막의 $(document).on('click', '#saveBtn', function() {...})을 보면 위에서 form내에 숨겨둔 submit 버튼을 강제로 클릭하는 것을 볼 수 있다. 이 때, required가 지정된 input 중 내용이 빈 것을 확인하여 사용자에게 경고창을 띄워준다.

실제로 writeBtn은 일반 버튼이기 때문에, submit 버튼의 validation과 상관없이 무조건 아래의 로직이 실행된다.
이를 막기 위해서 requiredInputHaveNullSpec 함수를 생성하여 필수 Input 중 Null인 것이 하나라도 있으면 로직을 끝내버리도록 (return false) 구성하였다.

새글 작성내가 쓴 글 클릭한 경우다른 사람이 쓴 글 클릭한 경우

2. admin 계정 추가하기

User Entity에 Role을 추가하고, 이를 통해 일반 계정과 관리자 계정을 분리하도록 할 것이다.

우선 domain/user 패키지 하위에 enums라는 패키지를 생성하고, UserRole이라는 Enum을 추가하였다.

❗️NOTE
enum이라는 이름의 패키지는 생성 불가하다. 정확히 말하면 생성은 가능하나 하위에 .java 파일을 만들 수 없다.

package com.freeboard01.domain.user.enums;

public enum UserRole {
    NORMAL, ADMIN
}

role은 간단하게 일반 유저를 뜻하는 NORMAL과 관리자인 ADMIN, 두가지만 생성하였다.

UserEntity 에도 컬럼을 추가해주자!

@Entity
@Getter
@Table(name = "user")
@NoArgsConstructor
public class UserEntity extends BaseEntity {

    @Column
    private String accountId;

    @Column
    private String password;

    @Setter
    @Column
    @Enumerated(EnumType.STRING)
    private UserRole role;

    @Builder
    public UserEntity(String accountId, String password, UserRole role){
        this.accountId = accountId;
        this.password = password;
        this.role = role;
    }

}

📌 @Enumerated(EnumType)
Enum을 Database에 저장할 때 어떤 형식으로 저장할 것인지를 명시한다. attribute로 추가가능한 EnumType은 STRINGORDINAL 인데, 디폴트는 ORDINAL 이다.
STRING으로 지정할 시 Enum의 이름이 그대로 Database 컬럼에 들어가고, ORDINAL로 지정할 시 index 값인 number가 들어가게 된다.

UserService에서 회원 가입 시 UserRole.NORMAL을 추가한 뒤 데이터 베이스에 저장하도록 로직을 변경해 주었다.

public Boolean join(UserForm user) {
        UserEntity userEntity = userRepository.findByAccountId(user.getAccountId());
        if(userEntity == null){
            UserEntity newUser = user.convertUserEntity();
            newUser.setRole(UserRole.NORMAL); //여기!!!
            userRepository.save(newUser);
            return true;
        }
        return false;
    }

UserApiControllerTest에서 회원가입 테스트를 실행해보자!
성공적으로 NORMAL role이 추가됐음을 확인 할 수 있다.

3. Specification 추가

현재 서비스에서 사용하고 있는 if문을 보자. 사실 비교값이 하나이고 간단하게 작성되어 있기 때문에 딱히 문제가 없어보이기는 한다.🤔 하지만 로직이 복잡해지면서 하나의 분기문 내에 여러개의 조건이 들어가게되고 (&&||로 엮일 것이다.) 가독성이 떨어질 가능성이 크다. 뿐만 아니라 동일한 조건을 판별하는 구문들이 여러 곳에서 반복적으로 사용될 것인데, 세부 조건이 바뀔 때마다 이를 하나씩 찾아내 바꾸는 것은 유지보수 측면에서 매우 좋지않다.

따라서 이를 Specification(명세)라는 이름의 일종의 validation을 수행하는 팩토리 클래스를 사용하는 것으로 변경할 것이다.

❗️NOTE
interface의 static 메소드는 body가 반드시 필요하며 오버라이딩이 불가능하다.

모든 Spec 클래스는 팩토리이기 때문에 interface나 추상 클래스를 사용한 구현이 불가하다. 단, 통일되도록 confirm이라는 이름의 static 메소드를 사용할 것을 염두에 두고 작성할 것이다.🤔

user/specification 패키지 추가하기

domain/user 패키지 하위에 specification 패키지를 만들고 IsWriterEqualToUserLoggedIn 클래스와 HaveAdminRoles 클래스를 추가해 줄것이다.

spec은 하나의 static 메소드를 수행함으로써 false/true를 판정하는 것이 목표이므로 단번에 의도가 들어날 수 있게 클래스 이름을 구체적으로 작성해주었다.

IsWriterEqualToUserLoggedIn

public class IsWriterEqualToUserLoggedIn {

    public static boolean confirm(UserEntity writer, UserEntity loginUser) {
        return writer.getAccountId().equals(loginUser.getAccountId());
    }
}

HaveAdminRoles

public class HaveAdminRoles {

    static List<UserRole> upperRoleList;

    static {
        upperRoleList = Arrays.asList(UserRole.ADMIN);
    }

    public static boolean confirm(UserEntity user){
        return upperRoleList.stream().anyMatch(role -> role.equals(user.getRole()));
    }
}

static { ... } 은 정적 변수로 선언된 upperRoleList를 초기화해주는 정적 변수 초기화 블럭이다.
이 블럭은 여러개 작성할 수 있으며 위에서 부터 차례로 읽어진다. 따라서 중복으로 초기화 한 경우에는 마지막 블럭의 값으로 최종 저장된다.

ref. anyMatch(), allMatch(), noneMatch()

BoardService

boardService의 update, delete 메소드를 Spec을 사용하도록 변경해주었다.

public Boolean update(BoardForm boardForm, UserForm userForm, long id) {
        UserEntity user = userRepository.findByAccountId(userForm.getAccountId());
        if(user == null){
            new Exception();
        }
        BoardEntity target = boardRepository.findById(id).get();
        if(IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) || HaveAdminRoles.confirm(user)){
            target.update(boardForm.convertBoardEntity(target.getWriter()));
            return true;
        }
        return false;
    }

    public boolean delete(long id, UserForm userForm) {
        UserEntity user = userRepository.findByAccountId(userForm.getAccountId());
        if(user == null){
            new Exception();
        }
        BoardEntity target = boardRepository.findById(id).get();
        if(IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) || HaveAdminRoles.confirm(user)){
            boardRepository.deleteById(id);
            return true;
        }
        return false;
    }

BoardServiceUnitTest

앞선 delete 테스트 두 개를 다음과 같이 수정하고, 관리자 계정일 경우 로그인 계정과 글 작성자가 일치하지 않아도 삭제를 수행하는 테스트를 추가하였다.

@ExtendWith({MockitoExtension.class, SpringExtension.class})
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml"})
public class BoardServiceUnitTest {

    @InjectMocks
    private BoardService sut;

    @Mock
    private BoardRepository mockBoardRepo;

    @Mock
    private UserRepository mockUserRepo;

    @Test
    @DisplayName("로그인한 유저와 글을 작성한 유저가 다를 경우 삭제를 진행하지 않는다.")
    public void delete1() {
        UserEntity writer = UserEntity.builder().accountId("mockUser").password("mockPass").build();
        UserForm userLoggedIn = UserForm.builder().accountId("wrongUser").password("wrongUser").build();
        BoardEntity boardEntity = BoardEntity.builder().contents("contents").title("title").writer(writer).build();

        given(mockUserRepo.findByAccountId(anyString())).willReturn(userLoggedIn.convertUserEntity());
        given(mockBoardRepo.findById(anyLong())).willReturn(Optional.of(boardEntity));

        sut.delete(anyLong(), userLoggedIn);
        verify(mockBoardRepo, never()).deleteById(anyLong());
    }

    @Test
    @DisplayName("로그인한 유저와 글을 작성한 유저가 동일할 경우 삭제를 수행한다.")
    public void delete2() {
        final String PASSWORD = "myPass";

        UserForm userForm = UserForm.builder().accountId("mockUser").password(PASSWORD).build();
        UserEntity userLoggedIn = userForm.convertUserEntity();
        BoardEntity boardEntity = BoardEntity.builder().writer(userLoggedIn).contents("contents").title("title").build();

        given(mockUserRepo.findByAccountId(anyString())).willReturn(userLoggedIn);
        given(mockBoardRepo.findById(anyLong())).willReturn(Optional.of(boardEntity));
        doNothing().when(mockBoardRepo).deleteById(anyLong());

        sut.delete(anyLong(), userForm);
        verify(mockBoardRepo, times(1)).deleteById(anyLong());
    }

    @Test
    @DisplayName("관리자 계정일 경우 삭제를 수행한다.")
    public void delete3() {
        UserForm userLoggedIn = UserForm.builder().accountId("admin").build();
        UserEntity userLoggedInEntity = userLoggedIn.convertUserEntity();
        userLoggedInEntity.setRole(UserRole.ADMIN);
        UserEntity writer = UserEntity.builder().accountId("mockUser").password("mockPass").build();

        BoardEntity boardEntity = BoardEntity.builder().writer(writer).contents("contents").title("title").build();

        given(mockUserRepo.findByAccountId(anyString())).willReturn(userLoggedInEntity);
        given(mockBoardRepo.findById(anyLong())).willReturn(Optional.of(boardEntity));

        sut.delete(anyLong(), userLoggedIn);
        verify(mockBoardRepo, times(1)).deleteById(anyLong());
    }

}

4. client 연동

client에 Role을 넘겨서 관리자 계정일 경우 수정/삭제 버튼을 항상 활성화 시킬 수 있도록 한다.

UserService

HomeController 에서 사용할 메소드를 추가하였다.

    public UserRole findUserRole(UserForm user){
        return userRepository.findByAccountId(user.getAccountId()).getRole();
    }

HomeController

@Controller
public class HomeController {

    private UserService userService;

    @Autowired
    public HomeController(UserService userService){
        this.userService = userService;
    }

    @GetMapping("/")
    public String home() {
        return "index";
    }

    @GetMapping("/board")
    public String board(HttpSession httpSession, Model model) {
        UserForm loginUser = (UserForm) httpSession.getAttribute("USER");
        if(loginUser != null ) {
            model.addAttribute("accountId", loginUser.getAccountId());
            // 추가한 로직
            model.addAttribute("accountRole" , userService.findUserRole(loginUser));
        }
        return "board";
    }

    @GetMapping("/join")
    public String join() { return "join"; }

    @GetMapping("/logout")
    public String logout(HttpSession httpSession) {
        httpSession.removeAttribute("USER");
        return "index";
    }

}

findUserRole을 사용하기 위해 UserService를 추가한 뒤, board 메소드에 model의 attribute로 UserRole 넣는 로직을 추가하였다.

board.hbs

accountRole 저장어드민 유저 판정

accountRole : 현재 로그인한 유저의 Role
adminRole : 관리자에 준하는 Role 배열
adminRole.includes(accountRole)로 관리자 계정인지 판단하여 분기시킨다.

톰캣을 실행해서 어드민 유저로 로그인 해보자!

어드민 유저로 로그인해서 일반 유저가 쓴 글을 클릭한 경우어드민이 수정을 수행한 뒤 모습

5. Exception 처리

커스텀 Exception 처리를 수행하도록 하겠다.
Exception을 사용하여 서버의 로직이 중단되는 것에 그치는 것이 아니라 client로 어떤 문제가 발생하였는지 전달할 필요가 있다.

Spring에서는 이를 위해 강력한 어노테이션을 제공하는데 바로 @ExceptionHandler 이다.

ExceptionHandler

@ControllerAdvice

  • 말그대로 컨트롤러를 보조하는 클래스임을 명시한다.
  • 즉, @Controller 어노테이션을 가지거나, xml 설정 파일에서 컨트롤러로 명시된 클래스에서 Exception이 발생되면 이를 감지하겠다는 것이다.
  • 유사하게 @RestControllerAdvice라는 어노테이션도 존재한다.
  • ControllerRestController만 ExceptionHandler의 감시 대상이 된다.
  • 즉, Service만 감시 대상으로 등록할 수는 없단 소리
  • 하지만 Controller에서 Service를 호출한 경우, Service에서 Exception이 발생해도 결국은 Controller로 부터 문제가 발생했음을 감지 → Handler가 작동한다.
  • @ControllerAdvice(com.freeboard01.api.BoardApi)와 같이 특정한 클래스만 명시하는 것도 가능하다.

@ResponseBody

  • @RestController@Controller@ResponseBody를 합친 것이란 건 앞에서 언급하였다.
  • ExceptionHandler는 컨트롤러 혹은 레스트컨트롤러가 아니다.
  • 응답값은 컨트롤러처럼 String 또는 ModelAndView만 가능하다.
  • 따라서 응답 객체를 반환하고자 하면 @ResponseBody 어노테이션을 메소드 위에 명시하여야 한다.
  • String이나 ModelAndView를 이용해 에러 코드 혹은 메세지만 반환하거나 Map으로 반환하는 것 client 측에서 Error를 처리함에 있어 일관성이 부족한 응답으로 인해 불편함이 많을 것이기에 객체를 활용해 통일된 형식을 지키도록 한다.

@ExceptionHandler

  • @ControllerAdvice 이 명시된 클래스 내부 메소드 에 사용한다.
  • Attribute로 Exception 클래스를 받는다.
  • 즉, RuntimeException.class 나 더 상위 클래스인 Exception.class 등을 넘기면 된다.
  • Custom Exception을 만들었다면 (보통은 RuntimeException을 상속 받았을 것이다.) 이를 넘기면 된다.
  • @ExceptionHandler(XXException.class) 라고 작성한 경우, @ControllerAdvice에서 명시한 클래스에서 throw new XXException( .. ) 이 발생하면 핸들러는 이를 감지하고 해당 메소드를 수행한다.
  • 메소드는 여러개 작성 할 수 있으며 이에 따라 @ExceptionHandler 에 다른 Attribute값을 넘김으로써 각 Exception을 다르게 처리 할 수 있다.

나는 RuntimeException을 상속받는 FreeBoardException을 만들었는데, 현시점에서는 이 클래스에서 모든 커스텀 예외 처리를 수행하도록 할 것이다.

util 패키지 하위에 exception 패키지를 만들고 ExceptionHandler 클래스를 추가하였다.
완성된 코드는 다음과 같다.

@ControllerAdvice
public class ExceptionHandler {

    @ResponseBody
    @org.springframework.web.bind.annotation.ExceptionHandler(FreeBoardException.class)
    public ResponseEntity<Error> exception(FreeBoardException exception){
        return new ResponseEntity<>(Error.create(exception.getExceptionType()), HttpStatus.OK);
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    static class Error{
        private int code;
        private int status;
        private String message;

        static Error create(BaseExceptionType exception){
           return new Error(exception.getErrorCode(), exception.getHttpStatus(), exception.getErrorMessage());
        }
    }

}

freeboardException

동일한 댑스에 freeboardException이라는 예외 처리 클래스를 만들었다.

public class FreeBoardException extends RuntimeException {

    @Getter
    private BaseExceptionType exceptionType;

    public FreeBoardException(BaseExceptionType exceptionType){
        super(exceptionType.getErrorMessage());
        this.exceptionType = exceptionType;
    }

}

사실상 Client로 전달되는 값은 static class Error이므로 본 클래스는 Handler가 감시할 커스텀 예외를 명시하기 위한 수단이라고 생각할 수 있다.
따라서 멤버변수로 ExceptionType을 추가하였다.

BaseExceptionType, UserExceptionType

domain 패키지 하위에 BaseExceptionType 클래스를 만들었다.

public interface BaseExceptionType {
    int getErrorCode();
    int getHttpStatus();
    String getErrorMessage();
}

BaseExceptionType 을 만든 이유는 모든 예외처리를 수행할 클래스가 freeboardException 이기에 각 도메인의 ExceptionType Enum을 업캐스팅하여 받아 올 수 있게 하기 위해서이다.
또한 이를 Error 객체로 만들 때 다운캐스팅하여 Enum 내 attribute를 convert 하기 위해서 @Getter 어노테이션을 사용했을 시 만들어지는 메소드와 동일한 추상 메소드를 포함하도록 하였다.

freeboardExceptionstatic class Error in ExceptionHandler
업캐스팅다운캐스팅

domain/user/enums 패키지 하위에 UserExceptionType 클래스를 만들었다.

@Getter
public enum UserExceptionType implements BaseExceptionType {

    NOT_FOUND_USER(1001, 200, "해당하는 사용자가 존재하지 않습니다."),
    DUPLICATED_USER(1002, 200, "이미 존재하는 사용자 아이디입니다."),
    WRONG_PASSWORD(1003, 200, "패스워드를 잘못 입력하였습니다."),
    LOGIN_INFORMATION_NOT_FOUND(1004, 200, "로그인 정보를 찾을 수 없습니다. (세션 만료)");

    private int errorCode;
    private int httpStatus;
    private String errorMessage;

    UserExceptionType(int errorCode, int httpStatus, String errorMessage) {
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errorMessage = errorMessage;
    }
}

❗️NOTE. ExceptionHandler의 Bean 설정

한참을 또 삽질했는데 🤔 아무리 Exception을 발생시켜도 Handler가 작동하지 않는 것이었다.
인터넷에 찾아봐도 다른 configuration에 대한 언급이 없었기에 최후의 시도라는 마음으로 dispatcher-servlet.xml<bean class="com.freeboard01.util.exception.ExceptionHandler"></bean>를 추가하였더니 그제서야 정상작동 되는 것을 볼 수 있었다.

예측하건데, Controller가 어노테이션 사용 설정으로 Bean으로 등록되고 필요 종속들이 자동 주입되는 것처럼 (혹은 Handler를 빈으로 등록하는 것처럼) ControllerAdvice인 클래스 또한 빈으로 명시해줄 필요가 있는가보다.

UserApiController

join 메소드 변경

    @PostMapping
    private void join(@RequestBody UserForm user){
        userService.join(user);
    }

UserService

join 메소드 변경

     public void join(UserForm user) {
        UserEntity userEntity = userRepository.findByAccountId(user.getAccountId());
        if (userEntity != null){
            throw new FreeBoardException(UserExceptionType.DUPLICATED_USER);
        }
        UserEntity newUser = user.convertUserEntity();
        newUser.setRole(UserRole.NORMAL);
        userRepository.save(newUser);
    }

톰캣을 실행 한 뒤 포스트맨으로 join 요청을 날려보도록 하겠다.

6. Exception 관련 전체 코드

2020.05.07 추가

UserApiController

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserApiController {

    private final HttpSession httpSession;
    private final UserService userService;

    @PostMapping
    private void join(@RequestBody UserForm user){
        userService.join(user);
    }

    @PostMapping(params = {"type=LOGIN"})
    private void login(@RequestBody UserForm user){
        userService.login(user);
        httpSession.setAttribute("USER", user);
    }
}

로그인의 반환값이 Boolean 이었던 것을 void 로 변경하고 예외처리가 일어나지 않은경우는 무조건 로그인 성공으로 간주하고 세션에 추가한다.

UserService

@Service
@Transactional
public class UserService {

    private UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserRole findUserRole(UserForm user){
        return userRepository.findByAccountId(user.getAccountId()).getRole();
    }

    public void join(UserForm user) {
        if (userRepository.findByAccountId(user.getAccountId()) != null){
            throw new FreeBoardException(UserExceptionType.DUPLICATED_USER);
        }
        UserEntity newUser = user.convertUserEntity();
        newUser.setRole(UserRole.NORMAL);
        userRepository.save(newUser);
    }

    public void login(UserForm user) {
        UserEntity userEntity = userRepository.findByAccountId(user.getAccountId());
        if (userEntity == null){
            throw new FreeBoardException(UserExceptionType.NOT_FOUND_USER);
        }
        if (userEntity.getPassword().equals(user.getPassword()) == false){
            throw new FreeBoardException(UserExceptionType.WRONG_PASSWORD);
        }
    }
}

로그인 메소드의 첫 번째 분기문은 계정 정보가 존재하지 않는 경우, 두 번째 분기문은 비밀번호를 잘못 입력한 경우이다.

아이디를 잘못 입력한 경우비밀번호를 잘못 입력한 경우

domain/board/enums/BoardExceptionType

@Getter
public enum  BoardExceptionType implements BaseExceptionType {

    NO_QUALIFICATION_USER(2001, 200, "권한없는 유저입니다."),
    NOT_MATCH_WRITER(2002, 200, "작성자만 수행가능한 작업입니다."),
    NOT_FOUNT_CONTENTS(2003, 200, "존재하지 않는 게시글입니다.");

    private int errorCode;
    private int httpStatus;
    private String errorMessage;

    BoardExceptionType(int errorCode, int httpStatus, String errorMessage) {
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errorMessage = errorMessage;
    }

}

기본적인 구조는 UserExceptionType과 일치한다.

BoardService

@Service
@Transactional
public class BoardService {

    private BoardRepository boardRepository;
    private UserRepository userRepository;

    @Autowired
    public BoardService(BoardRepository boardRepository, UserRepository userRepository) {
        this.boardRepository = boardRepository;
        this.userRepository = userRepository;
    }

    public Page<BoardEntity> get(Pageable pageable) {
        return boardRepository.findAll(PageUtil.convertToZeroBasePageWithSort(pageable));
    }

    public BoardEntity post(BoardForm boardForm, UserForm userForm) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        return boardRepository.save(boardForm.convertBoardEntity(user));
    }

    public void update(BoardForm boardForm, UserForm userForm, long id) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(id).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        if (IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) == false && HaveAdminRoles.confirm(user) == false) {
            throw new FreeBoardException(BoardExceptionType.NO_QUALIFICATION_USER);
        }

        target.update(boardForm.convertBoardEntity(target.getWriter()));
    }

    public void delete(long id, UserForm userForm) {
        UserEntity user = Optional.of(userRepository.findByAccountId(userForm.getAccountId())).orElseThrow(() -> new FreeBoardException(UserExceptionType.NOT_FOUND_USER));
        BoardEntity target = Optional.of(boardRepository.findById(id).get()).orElseThrow(() -> new FreeBoardException(BoardExceptionType.NOT_FOUNT_CONTENTS));

        if (IsWriterEqualToUserLoggedIn.confirm(target.getWriter(), user) == false && HaveAdminRoles.confirm(user) == false) {
            throw new FreeBoardException(BoardExceptionType.NO_QUALIFICATION_USER);
        }

        boardRepository.deleteById(id);
    }
}

Optional을 이용하여 NullPointerException을 처리하였다.

❗️Optional 참고링크

BoardApiController

@RestController
@RequestMapping("/api/boards")
@RequiredArgsConstructor
public class BoardApiController {

    private final HttpSession httpSession;
    private final BoardService boardService;

    @GetMapping
    public ResponseEntity<PageDto<BoardDto>> get(@PageableDefault(page = 1, size = 10, sort = "createdAt", direction = Sort.Direction.DESC )Pageable pageable){
        Page<BoardEntity> pageBoardList = boardService.get(pageable);
        List<BoardDto> boardDtoList = pageBoardList.stream().map(boardEntity -> BoardDto.of(boardEntity)).collect(Collectors.toList());
        return ResponseEntity.ok(PageDto.of(pageBoardList, boardDtoList));
    }

    @PostMapping
    public ResponseEntity<BoardDto> post(@RequestBody BoardForm form){
        if(httpSession.getAttribute("USER") == null){
            throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
        }
        BoardEntity savedEntity = boardService.post(form, (UserForm) httpSession.getAttribute("USER"));
        return ResponseEntity.ok(BoardDto.of(savedEntity));
    }

    @PutMapping("/{id}")
    public void update(@RequestBody BoardForm form, @PathVariable long id){
        if(httpSession.getAttribute("USER") == null){
            throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
        }
        boardService.update(form, (UserForm) httpSession.getAttribute("USER"), id);
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable long id){
        if(httpSession.getAttribute("USER") == null){
            throw new FreeBoardException(UserExceptionType.LOGIN_INFORMATION_NOT_FOUND);
        }
        boardService.delete(id, (UserForm) httpSession.getAttribute("USER"));
    }
}

front

board.hbsindex.hbs & join.hbs

ajax로 서버에 요청 후 응답받은 데이터에 따라 분기, response.message는 서버에서 보낸 Error 객체의 멤버 변수이다.

모든 코드는 Github에서 확인할 수 있습니다.

profile
🌱 😈💻 🌱
post-custom-banner

0개의 댓글