ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 우리 가게 다시 영업합니다
    Today I Learned 2022. 11. 30. 21:57

     

     

    다행스럽게도 오늘 오후에 리팩터링이 일차적으로는 끝나 가게 문을 다시 열 수는 있게 되었다. 언제 다시 닫게 될지는 모르지만 가게를 열 수 있다는 것에 감사하면서 열심히 장사하도록 하자.

     

     

     

    남아있던 리팩터링 작업 중 꽤나 신경써야 했던 부분 중의 하나는 예외처리 방식을 바꾸는 것이었다.

     

    사용자가 게임에 참가를 신청하는 동작을 받는 Application Layer의 JoinGameService가 RegisterToGameService였던 시절의 예외처리 방식은 다음과 같이 하나의 예외에 메세지를 다르게 해서 예외를 발생시키는 식이었다.

     

    // services/RegisterToGameService.java
    
    Game game = gameRepository.findById(gameId)
        .orElseThrow(() -> new RegisterGameFailed(
            "주어진 게임 번호에 해당하는 게임을 찾을 수 없습니다.")
    
    // ----
    
    applicantsAndMembers.forEach(person -> {
        if (person.userId().equals(accessedUserId)
            && (person.status().value().equals(RegisterStatus.PROCESSING)
            || person.status().value().equals(RegisterStatus.ACCEPTED))) {
            throw new RegisterGameFailed("이미 신청 중이거나 신청이 완료된 운동입니다.");
        }
    });
    
    // ----
    
    User user = userRepository.findById(accessedUserId)
        .orElseThrow(() -> new RegisterGameFailed(
            "주어진 사용자 번호에 해당하는 사용자를 찾을 수 없습니다."));
    
    // ----
    
    if (members.size() >= game.targetMemberCount().value()) {
        throw new RegisterGameFailed("참가 정원이 모두 차 참가를 신청할 수 없습니다.", game.id());
    }

     

    발생한 예외는 Controller에서 다음 과정을 거쳐 Error DTO에 담겨 응답으로 전달되고 있었다. SCREAMING_SNEAK_CASE로 표기된 것들은 에러 코드를 나타내는 상수를 정의한 것들이다.

     

    // controllers/RegisterController.java
    
    @ExceptionHandler(RegisterGameFailed.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public RegisterGameFailedErrorDto registerGameFailed(RegisterGameFailed exception) {
        Integer errorCode = mapToErrorCode(exception.getMessage());
        String errorMessage = errorCode.equals(DEFAULT)
            ? "알 수 없는 에러입니다."
            : exception.getMessage();
        return new RegisterGameFailedErrorDto(errorCode, errorMessage);
    
        return new RegisterGameFailedErrorDto(
            errorCode,
            errorMessage,
            exception.getGameId()
        );
    }
    
    private Integer mapToErrorCode(String errorMessage) {
        return switch (errorMessage) {
            case "주어진 게임 번호에 해당하는 게임을 찾을 수 없습니다." -> GAME_NOT_FOUND;
            case "이미 신청이 완료된 운동입니다." -> ALREADY_REGISTERED_GAME;
            case "주어진 사용자 번호에 해당하는 사용자를 찾을 수 없습니다." -> USER_NOT_FOUND;
            case "참가 정원이 모두 차 참가를 신청할 수 없습니다." -> FULLY_PARTICIPANTS;
            default -> DEFAULT;
        };
    }

     

    이 예외처리 과정은 리팩터링을 거쳐 예외 상황에 맞는 이름의 고유한 예외를 발생시키고, 발생한 예외들은 각각의 Exception Handler에서 응답으로 전달되는 구조로 바뀌었다.

     

    // services/JoinGameService.java
    
    User currentUser = userRepository.findById(currentUserId)
        .orElseThrow(() -> new UserNotFound(currentUserId));
    
    Game game = gameRepository.findById(gameId)
        .orElseThrow(() -> new GameNotFound(gameId));
    // models/Game.java
    
    if (alreadyJoined(currentUser)) {
        throw new AlreadyJoinedGame(id);
    }
    
    if (isFull()) {
        throw new GameIsFull(id);
    }
    // controllers/RegisterController.java
    
    @ExceptionHandler(UserNotFound.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String userNotFound() {
        return "User Not Found";
    }
    
    @ExceptionHandler(GameNotFound.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String gameNotFound() {
        return "Game Not Found";
    }
    
    @ExceptionHandler(AlreadyJoinedGame.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String alreadyJoinedGame() {
        return "Already Joined Game";
    }
    
    @ExceptionHandler(GameIsFull.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String gameIsFull() {
        return "Game Is Full";
    }

     

    이렇게 되자 모델이나 Service, UI 레이어에서의 예외상황을 검증하는 테스트에서 발생시키는 에러를 명칭으로 좀 더 명확하게 드러낼 수 있게 되었다. 사실 에러 코드가 있다 한들 실제로 프론트엔드에서 그 에러 코드를 이용해서 에러 메세지를 UI 컴포넌트에 표출하는 것은 에러 코드만 가지고는 의미를 파악하기 어렵기 때문에 좋은 방식이 아니다. 그럼에도 여태까지 굳이 에러 코드를 만들어가면서까지 에러를 전달하고 있었던 이유는 지금까지 프론트엔드에서 예외처리를 해주고 있지 않았기 때문이었다.

     

     

    게임 신청과 관련된 부분은 프론트엔드에서 검사할 수 있는 예외는 아니므로 잠시 다른 예시로 로그인이나 게시글 작성을 들어보자면, 그간 백엔드 예외처리와 프론트엔드 예외처리가 중복되는 경우의 메세지 관리를 어떻게 해줘야 할지 곤란하다는 생각이 들어 백엔드에서 예외처리를 잘 전달하면 프론트엔드에서는 전달받은 메세지만 잘 보여주고, 굳이 직접 예외처리를 해주지 않아도 되는 것 아닌가 생각하고 있었다.

     

    그러나 3기 후배님께서 올린 질문TIL을 보면서 백엔드에서는 오작동되는 것을 방지한다는 느낌으로 예외처리를 하고, UI나 사용자 경험 측면에서는 프론트엔드에서 예외처리를 해서 즉각적으로 결과를 돌려주는 방식으로 예외처리를 하는 것이 더 효과적이겠다는 생각이 들게 되었다.

     

     

    프론트엔드에서는 예외처리를 어떻게 해줄 수 있을지 방식을 간략하게 알아본 내용은 다음과 같다.

    • react-hook-form 라이브러리의 custom hook인 useForm을 사용하는 경우, 각 입력 필드 요소에 전달한 register 객체에 예외로 처리할 조건들을 지정해주고, formState 객체에 담긴 error 객체의 값들을 전달해 UI 컴포넌트에서 출력되도록 할 수 있다.

    • 또는 Store를 이용해 전역 상태 관리 형태로 form을 구현하는 경우, 입력된 상태를 전달하는 API 요청을 수행하기 전 상태의 값들을 검사해 예외 상황일 경우 API 호출을 중단하고 에러 메세지 상태를 활성화시킨 뒤, publish를 수행하면 UI 컴포넌트에서 활성화된 에러 메세지를 출력하는 방식을 생각해볼 수 있다.

     

     

    다시 본론으로 돌아와서, 게임 신청 예외처리를 단순히 에러 메세지만을 발생시키는 구조로 변경하자 로직을 수정해야 할 곳이 발생했다. 운동 모집 게시글 목록을 조회하면서 바로 게임을 신청하는 경우, 지금의 로직에서는 에러가 발생한 게시글에 에러 메세지를 띄워줘야 했기 때문에 어떤 게시글에서 에러가 발생했는지 판별해야 했다. 이를 위해 에러 DTO에 Game Id를 같이 반환해주고 있었는데, 이제는 해당 방법으로는 예외처리를 해줄 수 없게 되었다.

     

     

     

     

    그래서 생각해낸 방법은 에러 메세지를 Modal을 이용해 보여주는 방식이다. 사용자는 자신이 어떤 게시글에 신청했는지 알기 때문에 Modal로 안내가 되더라도 그게 어떤 게시글이었는지 알 수 있다. 즉 문제의 복잡도가 '에러가 어떤 게시글에서 발생했는지'에서 '에러가 발생했는지'의 영역으로 축소될 수 있다.

     

    작업을 이행해야 하는 당위성이 생긴 만큼 작업 과정을 충분히 설계해서 진행해야 하겠다.

     

    한편으로는 작업 계획을 추가할 때 단순히 어렵고 복잡하게만 만들려고 하는 것이 아니라, 사용자가 얼마나 사용하기 편할 것인지 생각하면서 작업을 추가할 수 있어야겠다는 생각이다.

     

     

     

     

    작업 내역

    - https://github.com/hsjkdss228/smash-backend/pull/26

    - https://github.com/hsjkdss228/smash-frontend/pull/36

     

     

     

     

     

     

    댓글

Designed by Tistory.