Exception 처리 구현

MeteorLee·2023년 4월 24일
0

문제 해결

목록 보기
5/6
post-thumbnail

상황

스프링 부트 프로젝트에서 회원 가입을 하는 상황에서 여러 정보를 받는 상황에서 아이디의 길이가 짧거나 비밀번호가 양식에 맞지 않는 등 여러 종류의 Exception이 발생하게 된다. 이런 예외를 어떻게 하면 한 곳에서 처리하고 더 간편한 방법으로 처리할 수 있는 방식을 찾으면서 내가 겪었던 일들이다.

프로젝트

  • java
  • springboot
  • spring security
  • spring data jpa
  • h2 DB

초기 생각

내가 가장 중요하게 생각한 점은 공통화였다. 지금은 내가 회원 가입에서 받는 정보 중에서 내가 정한 양식(regex)에 맞지 않는 정보에 대한 예외를 발생시키지만, 다른 개발자는 게시판이나 댓글과 같은 상황에서도 이런 정보들의 양식을 검증하는 일을 할 것이라고 생각했다. 따라서 회원 정보를 검증하는 로직이라는 생각보다 어떤 정보든 내가 검증하길 원하는 정보는 어느 곳에서나 검증하고 그런 검증에 발생하는 오류 메세지를 한 곳에서 처리하고 예외처리 또한 한 곳에서 만드는 것에 집중했다.

  • 검증 로직을 어느 곳에서나 사용할 수 있게 만들고 싶다.
  • 에러 메세지를 한 곳에서 관리하고 싶다.
  • 예외 처리 로직을 한 곳에서 처리하고 싶다.
  • 공통된 부분은 모아서 처리하고 싶다.

구현

메세지 공통화

Validation을 통해서 정보를 검증하는 로직의 메세지를 공통화하고 싶었기에 스프링의 MessageSource를 이용하였다.

MessageSource를 이용하기 위해 먼저 Configuration에 Bean으로 등록하고

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    /**
     * validation 메세지 위치
     *
     * @return the message source
     */
    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
        source.setBasename("classpath:config/messages/message");
        source.setDefaultEncoding("UTF-8");
        source.setDefaultLocale(Locale.KOREA);
        return source;
    }

}
  • MessageSource 빈 등록
  • message properties 파일 위치 지정
  • 인코딩, 지역 설정

현재 프로젝트는 한국에서 사용할 예정이고 이를 default로 설정하였다.

MemberSaveForm.account.NotEmpty=계정을 입력해주세요.
MemberSaveForm.account.Email=이메일 형식이 아닙니다.
MemberSaveForm.password.NotEmpty=비밀번호를 입력해주세요.
MemberSaveForm.password.Pattern=비밀번호는 숫자+문자를 조합한 6~12자리만 입력하실 수 있습니다.
...

현재는 Member 객체와 관련된 메세지만 존재하지만 다른 개발자가 쉽게 메세지를 추가할 수 있다.

ex)


BoardSaveForm.title.NotEmpty=제목을 입력해주세요
BoardSaveForm.title.Lenth=제목의 길이는 3~20글자 사이입니다.
...

검증 순서

회원 가입을 진행할 때 아이디와 이름이 동시에 검증을 통과하지 못하면 어떻게 될까? 처음에는 이런 생각을 하지 않고 구현하였는데 Swagger UI에서 검증하는 과정에서 아이디의 값을 검증을 통과하지 못하게 입력하였는데 이름의 길이에 문제가 있다는 메세지를 받게 되었다.

이런 상황에서 나는 위에서부터 즉 아이디 - 비밀번호 - 이름 등의 순서대로 검증을 통과하지 못하는 정보에 관한 에러 메세지를 발생시키길 원했다. 그래서 @GroupSequence에 순서를 나타내는 ValidationStep 클래스로 순서를 만들어 검증 순서를 정해주었다.

ValidationSteps

public class ValidationSteps {

    public interface Step1 {}
    public interface Step2 {}
    public interface Step3 {}
    public interface Step4 {}
    public interface Step5 {}
    public interface Step6 {}
    public interface Step7 {}
    public interface Step8 {}
    public interface Step9 {}
    public interface Step10 {}
}

만약 더 많은 검증을 원하고 이를 위한 순서를 생성하고 싶다면 추가적으로 쉽게 만들 수 있다.

MemberSaveForm

@Data
@AllArgsConstructor
@GroupSequence({
        MemberSaveForm.class,
        ValidationSteps.Step1.class,
        ValidationSteps.Step2.class,
        ValidationSteps.Step3.class,
        ValidationSteps.Step4.class,
        ValidationSteps.Step5.class,
        ValidationSteps.Step6.class,
        ValidationSteps.Step7.class,
        ValidationSteps.Step8.class,
        ValidationSteps.Step9.class,
        ValidationSteps.Step10.class,
})
public class MemberSaveForm {

    @NotEmpty(groups = ValidationSteps.Step1.class,
            message = "{MemberSaveForm.account.NotEmpty}")
    @Email(groups = ValidationSteps.Step2.class,
            message = "{MemberSaveForm.account.Email}")
    private String account;

    @NotEmpty(groups = ValidationSteps.Step3.class,
            message = "{MemberSaveForm.password.NotEmpty}")
    // 숫자와 문자 포함 형태의 6~12자리 이내의 암호 정규식
    @Pattern(regexp = "^[A-Za-z0-9]{6,12}$", groups = ValidationSteps.Step4.class,
            message = "{MemberSaveForm.password.Pattern}")
    private String password;

    @NotEmpty(groups = ValidationSteps.Step5.class,
            message = "{MemberSaveForm.name.NotEmpty}")
    @Length(min = 2, max = 5, groups = ValidationSteps.Step6.class,
            message = "{MemberSaveForm.name.Length}")
    private String name;

    @NotNull(groups = ValidationSteps.Step7.class,
            message = "{MemberSaveForm.birth.NotEmpty}")
    @Past(groups = ValidationSteps.Step8.class,
            message = "{MemberSaveForm.birth.Past}")
    private LocalDate birth;

    @NotNull(groups = ValidationSteps.Step9.class,
            message = "{MemberSaveForm.phoneNumber.NotEmpty}")
    @Pattern(regexp = "^01(?:0|1|[6-9])(?:\\d{3}|\\d{4})\\d{4}$", groups = ValidationSteps.Step10.class,
            message = "{MemberSaveForm.phoneNumber.Pattern}")
    private String phoneNumber;

}

이를 이용해서 내가 원하는 순서대로 검증할 수 있는 로직을 구현하였다. @GroupSequence를 사용하기 위해서 @Valid가 아닌 @Validated 사용해야 했다.

예외 처리

@ControllerAdvice 어노테이션을 이용하여 하나의 클래스에서 예외를 처리했다. 예외 처리를 모아서 한 군데에서 처리했다.

RegisterExceptionAdvice

@ControllerAdvice
public class RegisterExceptionAdvice {

    /**
     * 사용중인 계정 에러 처리, 400
     *
     * @return the http entity
     */
    @ExceptionHandler(AlreadyRegisteredUserException.class)
    public HttpEntity<Response> alreadyRegisteredUserExceptionHandler() {

        Response response =
                new Response("400", "이미 사용중인 아이디입니다.");
        return ResponseEntity.badRequest().body(response);

    }

    /**
     * validation 실패시 에러 처리, 400
     *
     * @param e the e
     * @return the response entity
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Response> methodValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        String errorMessage = bindingResult.getAllErrors().get(0).getDefaultMessage();
        Response response = new Response("400", errorMessage);
        return ResponseEntity.badRequest().body(response);
    }

    /**
     * Aes256 암호화, 복호화 서버 에러, 500
     *
     * @return the response entity
     */
    @ExceptionHandler(AES256EncodingException.class)
    public ResponseEntity<Response> AES256EncodingException() {

        Response response = new Response("500", "서버 장애입니다.");
        return ResponseEntity.internalServerError().body(response);
    }
}

회원이나 모든 등록 관련한 예외 처리를 할 수 있도록 만들었고 @ExceptionHandler 어노테이션을 붙인 추가적인 예외처리도 가능하다.

내가 예외 처리 로직을 짜면서 신경 쓴 부분은 어떤 정보를 클라이언트 서버로 보내야 하는 것이었다. 지금 내가 짠 Response는 다음과 같다.

Response

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Response {
    private String code;
    private String message;
}

지금은 2개의 변수만을 가지지만 초기 Response클래스를 만들 때는 변수가 4~5개 정도가 있었다. 서버의 상태를 조금 더 자세하게 표현하여 클라이언트 서버에서 구축하는데 도움을 주려고 했다. 그런데 이 방식이 그렇게 좋지 않다는 생각을 하게 되었다.

  • 일단 많은 정보를 주고 받는 다는 것은 결국 서버간의 통신하는데 부담이 커진다는 것이다. 가볍게 만들수록 비용은 적게, 속도는 빠르게 작동할 것이다.
  • 내가 프런트 개발자들을 위해서 많은 정보를 적는 것이 좋아보이지만 오히려 많은 정보를 적을수록 이 응답 객체를 알아보기 힘들어 진다는 것이다. 내가 메서드의 매개변수로 객체 1개만 주고 받는 것과 객체의 모든 변수를 주고 받는다고 생각하면 적을수록 좋은 것이다.
  • 많은 정보를 준다는 것은 서버에서 어떤 방식으로 동작하는 지에 대한 정보를 많이 주는 것으로 보안에도 좋지 않다. 민감한 데이터를 실수로 보낼 수도 있고 Response객체의 정보가 많아서 실제 Member객체의 구성을 예측하게 만들 수도 있으니 조심해야한다.
  • 프런트 개발자 분들이 정보가 적을 수록 최소한의 정보를 다루고 이를 통해서 주고 받는 과정에서 오류가 발생할 가능성도 적어진다. 거기다가 프런트 개발자 분들의 개발 자유성이 높아지기에 서로간에 더 높은 질의 코드를 만들어 낼 수 있다.

사실 간단한 생각으로 Response 코드의 변수는 뭘 담느냐를 생각했었는데 사실 내가 먼저 고려해야했던 점은 통신이라는 개념이고 이후에는 보안 그리고 다른 개발자들의 관점에서 바라보는 것이었다.

생각

백엔드

백엔드의 다른 개발자가 내가 만든 API를 사용한다는 생각으로 최대한 공통화해서 구현하고 쉽게 추가, 삭제, 수정할 수 있도록 코드를 만들었는데 쉽지 않다는 생각을 많이 했다. 제일 크게 와 닿은게 중간에 주말 동안 개인적인 일이 있어서 코드를 많이 보지 못했는데 겨우 2일 동안 코드를 보지 않았을 뿐인데, "내가 이렇게 코드를 짰었나? 음... 이거 왜 이렇게 짯지? 이거 작동하려면 그냥 매개변수만 넣으면 되나?" 정말 많은 생각을 하게 만들었다.

협업의 과정에서 분명 내가 만든 코드를 남이 보게 될 것이기에 코드 하나를 짜더라도 더 좋은 코드를 생각하게 되었다.

프런트 엔드

'내가 더 많은 정보를 주면 더 좋지 않을까?' 라는 짧은 생각을 돌아보게 되었다. 내가 생각하는 로직의 대부분의 매개변수를 Response에 넣어서 다시 보내주면 API를 만들기 편하겠지? 라는 이기적인 생각, 내가 만약 5~6개나 되는 매개변수를 가진 API를 사용해야 한다는 생각을 해보면 쉽지 않다는 것을 알고 있으면서도 이런 잘못된 행동을 했다. 객체지향적인 관점에서도 객체 사이에선 그저 단순한 메세지로 통신하고 구현은 각자의 객체에게 맡겨야 하는데 내가 프런트 엔드의 API에도 간섭하려고 했던 아주 잘못된 행동을 할 뻔했었던 것이다.

앞으로 협업의 과정에서 내가 다른 개발자를 위한다는 생각으로 잘못된 코드를 만드는 일을 하지 않도록 노력할 것이다.

마무리

다른 개발자들의 협업을 생각하며 더 좋은 코드를 짜기 위해 노력하는 일이 정말 쉽지 않다고 느꼈다. 특히, 프런트 개발자들과 협업하는 과정을 생각하면서 만들었던 Response 객체를 만드는 과정이 많은 생각을 하게 만들었다.

더 좋은 코드를 만드는 일에는 정말 많은 생각이 필요하다는 것을 다시 한번 느끼게 되었다.

profile
코딩 시작

0개의 댓글